From 96b95a24eeec2c760dfd08b7ac98868860a890c0 Mon Sep 17 00:00:00 2001 From: Boyd Johnson Date: Wed, 8 Feb 2023 16:57:15 -0600 Subject: [PATCH] Add rust bindings (#12606) This adds updated Rust bindings that have been located at [nbigaouette/onnxruntime-rs](https://github.com/nbigaouette/onnxruntime-rs). check out the build instructions included in this PR at /rust/BUILD.md. Changes to the bindings included in this PR: - The bindings are generated with the build script on each build - The onnxruntime shared library is built with ORT_RUST_STRATEGY=compile which is now the default. - A memory leak was fixed where a call to free wasn't called - Several small memory errors were fixed - Session is Send but not Sync, Environment is Send + Sync - Inputs and Outputs can be ndarray::Arrays of many different types. Some commits can be squashed, if wanted, but were left unsquashed to show differences between old bindings and new bindings. This PR does not cover packaging nor does it include the Rust bindings withing the build system. For those of you who have previous Rust code based on the bindings, these new bindings can be used as a `path` dependency or a `git` dependency (though I have not tested this out). The work addressed in this PR was discussed in #11992 --- .gitignore | 4 + rust/BUILD.md | 48 ++ rust/Cargo.toml | 5 + rust/LICENSE-APACHE | 201 +++++ rust/LICENSE-MIT | 21 + rust/README.md | 196 +++++ rust/onnxruntime-sys/Cargo.toml | 35 + rust/onnxruntime-sys/build.rs | 429 ++++++++++ rust/onnxruntime-sys/examples/c_api_sample.rs | 395 +++++++++ rust/onnxruntime-sys/src/lib.rs | 15 + rust/onnxruntime/Cargo.toml | 43 + rust/onnxruntime/examples/issue22.rs | 55 ++ rust/onnxruntime/examples/print_structure.rs | 47 + rust/onnxruntime/examples/sample.rs | 83 ++ rust/onnxruntime/src/download.rs | 113 +++ rust/onnxruntime/src/download/language.rs | 25 + .../language/machine_comprehension.rs | 127 +++ rust/onnxruntime/src/download/vision.rs | 45 + .../vision/body_face_gesture_analysis.rs | 43 + .../domain_based_image_classification.rs | 30 + .../download/vision/image_classification.rs | 350 ++++++++ .../src/download/vision/image_manipulation.rs | 86 ++ .../object_detection_image_segmentation.rs | 107 +++ rust/onnxruntime/src/environment.rs | 373 ++++++++ rust/onnxruntime/src/error.rs | 249 ++++++ rust/onnxruntime/src/lib.rs | 560 ++++++++++++ rust/onnxruntime/src/memory.rs | 81 ++ rust/onnxruntime/src/session.rs | 806 ++++++++++++++++++ rust/onnxruntime/src/tensor.rs | 31 + rust/onnxruntime/src/tensor/construct.rs | 34 + rust/onnxruntime/src/tensor/ndarray_tensor.rs | 210 +++++ .../src/tensor/ort_input_tensor.rs | 325 +++++++ .../src/tensor/ort_output_tensor.rs | 347 ++++++++ rust/onnxruntime/tests/data/mnist_5.jpg | Bin 0 -> 555 bytes rust/onnxruntime/tests/data/mushroom.png | Bin 0 -> 106499 bytes rust/onnxruntime/tests/data/upsample.onnx | Bin 0 -> 1861 bytes rust/onnxruntime/tests/integration_tests.rs | 555 ++++++++++++ rust/rustfmt.toml | 2 + 38 files changed, 6076 insertions(+) create mode 100644 rust/BUILD.md create mode 100644 rust/Cargo.toml create mode 100644 rust/LICENSE-APACHE create mode 100644 rust/LICENSE-MIT create mode 100644 rust/README.md create mode 100644 rust/onnxruntime-sys/Cargo.toml create mode 100644 rust/onnxruntime-sys/build.rs create mode 100644 rust/onnxruntime-sys/examples/c_api_sample.rs create mode 100644 rust/onnxruntime-sys/src/lib.rs create mode 100644 rust/onnxruntime/Cargo.toml create mode 100644 rust/onnxruntime/examples/issue22.rs create mode 100644 rust/onnxruntime/examples/print_structure.rs create mode 100644 rust/onnxruntime/examples/sample.rs create mode 100644 rust/onnxruntime/src/download.rs create mode 100644 rust/onnxruntime/src/download/language.rs create mode 100644 rust/onnxruntime/src/download/language/machine_comprehension.rs create mode 100644 rust/onnxruntime/src/download/vision.rs create mode 100644 rust/onnxruntime/src/download/vision/body_face_gesture_analysis.rs create mode 100644 rust/onnxruntime/src/download/vision/domain_based_image_classification.rs create mode 100644 rust/onnxruntime/src/download/vision/image_classification.rs create mode 100644 rust/onnxruntime/src/download/vision/image_manipulation.rs create mode 100644 rust/onnxruntime/src/download/vision/object_detection_image_segmentation.rs create mode 100644 rust/onnxruntime/src/environment.rs create mode 100644 rust/onnxruntime/src/error.rs create mode 100644 rust/onnxruntime/src/lib.rs create mode 100644 rust/onnxruntime/src/memory.rs create mode 100644 rust/onnxruntime/src/session.rs create mode 100644 rust/onnxruntime/src/tensor.rs create mode 100644 rust/onnxruntime/src/tensor/construct.rs create mode 100644 rust/onnxruntime/src/tensor/ndarray_tensor.rs create mode 100644 rust/onnxruntime/src/tensor/ort_input_tensor.rs create mode 100644 rust/onnxruntime/src/tensor/ort_output_tensor.rs create mode 100644 rust/onnxruntime/tests/data/mnist_5.jpg create mode 100644 rust/onnxruntime/tests/data/mushroom.png create mode 100644 rust/onnxruntime/tests/data/upsample.onnx create mode 100644 rust/onnxruntime/tests/integration_tests.rs create mode 100644 rust/rustfmt.toml diff --git a/.gitignore b/.gitignore index 26620d1bd5214..739ec17ca2fce 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,7 @@ onnxruntime/python/version_info.py # clangd .cache/ compile_commands.json +# Rust specific +rust/**/target +rust/**/Cargo.lock +rust/onnxruntime/synset.txt diff --git a/rust/BUILD.md b/rust/BUILD.md new file mode 100644 index 0000000000000..68500c7fc624a --- /dev/null +++ b/rust/BUILD.md @@ -0,0 +1,48 @@ +# Building and testing the Rust bindings + +These instructions require cargo and rustc. +To get these follow the instructions at [https://rustup.rs](https://rustup.rs) +The instructions compile the onnxruntime along with the bindings, +so require `cmake`, a python 3 interpreter, clang (needed to parse the C headers to generate the Rust bindings), +and the platform compiler to compile onnxruntime. + +## Local setup of onnxruntime repo + +```sh + git clone https://github.com/microsoft/onnxruntime + cd onnxruntime + git submodule update --init --recursive +``` + +## cargo build both crates + +from the root of onnxruntime repo + +```sh + CARGO_TARGET_DIR=build/rust cargo build --manifest-path rust/Cargo.toml +``` + +The CARGO_TARGET_DIR environment variable puts the build artifacts in `onnxruntime/build/rust` +instead of `onnxruntime/rust/target`. + +## cargo test both crates + +```sh + CARGO_TARGET_DIR=build/rust cargo test --manifest-path rust/Cargo.toml --features model-fetching +``` + +### cargo test both crates while specifying the absolute path to the OnnxRuntime shared library. + +```sh + RUST_ONNXRUNTIME_LIBRARY_PATH= CARGO_TARGET_DIR=build/rust cargo test --manifest-path rust/Cargo.toml --features model-fetching +``` + +## cargo test with sanitizer support + +**If you are using a nightly Rust compiler and are on one the platforms listed in [Rust sanitizer support](https://doc.rust-lang.org/beta/unstable-book/compiler-flags/sanitizer.html).** + +where `$SAN` is one of `address`, `thread`, `memory` or `leak` + +```sh + RUSTFLAGS="-Zsanitizer=$SAN" CARGO_TARGET_DIR=build/rust cargo test --manifest-path rust/Cargo.toml --features model-fetching --target -Z build-std -- --test-threads=1 +``` diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000000000..7c33647c5d3da --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [ + "onnxruntime-sys", + "onnxruntime", +] diff --git a/rust/LICENSE-APACHE b/rust/LICENSE-APACHE new file mode 100644 index 0000000000000..e0284d8a8d512 --- /dev/null +++ b/rust/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2020 Nicolas Bigaouette + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/rust/LICENSE-MIT b/rust/LICENSE-MIT new file mode 100644 index 0000000000000..2b6d07c1daf81 --- /dev/null +++ b/rust/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Nicolas Bigaouette + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000000000..14b9e8cd632b4 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,196 @@ +# ONNX Runtime + +These are Rust bindings to +[Microsoft's ONNX Runtime](https://github.com/microsoft/onnxruntime). + +This project consists of two crates: + +* [`onnxruntime-sys`](onnxruntime-sys): Low-level binding to the C API; +* [`onnxruntime`](onnxruntime): High-level and safe API. + +The `build.rs` script supports downloading pre-built versions of the Microsoft ONNX Runtime, +which provides the following targets: + +CPU: + +* Linux x86_64 +* macOS x86_64 +* macOS aarch64 +* Windows i686 +* Windows x86_64 + +GPU: + +* Linux x86_64 +* Windows x86_64 + +--- + +**WARNING**: + +* This is an experiment and work in progress; it is _not_ complete/working/safe. Help welcome! +* Basic inference works, see [`onnxruntime/examples/sample.rs`](onnxruntime/examples/sample.rs) or [`onnxruntime/tests/integration_tests.rs`](onnxruntime/tests/integration_tests.rs) +* ONNX Runtime has many options to control the inference process but those options are not yet exposed. + +--- + +## Setup + +Three different strategy to obtain the ONNX Runtime are supported by the `build.rs` script: + +1. Download a pre-built binary from upstream; +2. Point to a local version already installed; +3. Compile from source. + +To select which strategy to use, set the `ORT_RUST_STRATEGY` environment variable to: + +1. `download`: Download prebuilt onnxruntime; +2. `system`: To use a locally installed version (use `ORT_RUST_LIB_LOCATION` environment variable to point to the install path) +3. `compile`: To compile the library. This is the default. + +The `download` strategy supports downloading a version of ONNXRuntime that supports CUDA. To use this, set the +environment variable `ORT_RUST_USE_CUDA=1` (only supports Linux or Windows). + +### Note on 'ORT_RUST_STRATEGY=system' + +When using `ORT_RUST_STRATEGY=system`, executing a built crate binary (for example the tests) might fail, at least on macOS, +if the library is not installed in a system path. An error similar to the following happens: + +```text +dyld: Library not loaded: @rpath/libonnxruntime.1.7.1.dylib + Referenced from: onnxruntime-rs.git/target/debug/deps/onnxruntime_sys-22eb0e3e89a0278c + Reason: image not found +``` + +To fix, one can either: + +* Set the `LD_LIBRARY_PATH` environment variable to point to the path where the library can be found. +* Adapt the `.cargo/config` file to contain a linker flag to provide the **full** path: + + ```toml + [target.aarch64-apple-darwin] + rustflags = ["-C", "link-args=-Wl,-rpath,/full/path/to/onnxruntime/lib"] + ``` + +See [rust-lang/cargo #5077](https://github.com/rust-lang/cargo/issues/5077) for more information. + +## Example + +The C++ example that uses the C API +([`C_Api_Sample.cpp`](https://github.com/microsoft/onnxruntime/blob/v1.3.1/csharp/test/Microsoft.ML.OnnxRuntime.EndToEndTests.Capi/C_Api_Sample.cpp)) +was ported to both the low level crate (`onnxruntime-sys`) and the high level on (`onnxruntime`). + +### onnxruntime-sys + +To run this example ([`onnxruntime-sys/examples/c_api_sample.rs`](onnxruntime-sys/examples/c_api_sample.rs)): + +```sh +# Download the model (SqueezeNet 1.0, ONNX version: 1.3, Opset version: 8) +❯ curl -LO "https://github.com/onnx/models/raw/main/vision/classification/squeezenet/model/squeezenet1.0-8.onnx" +❯ cargo run --example c_api_sample +[...] + Finished dev [unoptimized + debuginfo] target(s) in 1.88s + Running `target/debug/examples/c_api_sample` +Using Onnxruntime C API +2020-08-09 09:37:41.554922 [I:onnxruntime:, inference_session.cc:174 ConstructorCommon] Creating and using per session threadpools since use_per_session_threads_ is true +2020-08-09 09:37:41.556650 [I:onnxruntime:, inference_session.cc:830 Initialize] Initializing session. +2020-08-09 09:37:41.556665 [I:onnxruntime:, inference_session.cc:848 Initialize] Adding default CPU execution provider. +2020-08-09 09:37:41.556678 [I:onnxruntime:test, bfc_arena.cc:15 BFCArena] Creating BFCArena for Cpu +2020-08-09 09:37:41.556687 [V:onnxruntime:test, bfc_arena.cc:32 BFCArena] Creating 21 bins of max chunk size 256 to 268435456 +2020-08-09 09:37:41.558313 [I:onnxruntime:, reshape_fusion.cc:37 ApplyImpl] Total fused reshape node count: 0 +2020-08-09 09:37:41.559327 [I:onnxruntime:, reshape_fusion.cc:37 ApplyImpl] Total fused reshape node count: 0 +2020-08-09 09:37:41.559476 [I:onnxruntime:, reshape_fusion.cc:37 ApplyImpl] Total fused reshape node count: 0 +2020-08-09 09:37:41.559607 [V:onnxruntime:, inference_session.cc:671 TransformGraph] Node placements +2020-08-09 09:37:41.559615 [V:onnxruntime:, inference_session.cc:673 TransformGraph] All nodes have been placed on [CPUExecutionProvider]. +2020-08-09 09:37:41.559639 [I:onnxruntime:, session_state.cc:25 SetGraph] SaveMLValueNameIndexMapping +2020-08-09 09:37:41.559787 [I:onnxruntime:, session_state.cc:70 SetGraph] Done saving OrtValue mappings. +2020-08-09 09:37:41.560252 [I:onnxruntime:, session_state_initializer.cc:178 SaveInitializedTensors] Saving initialized tensors. +2020-08-09 09:37:41.563467 [I:onnxruntime:, session_state_initializer.cc:223 SaveInitializedTensors] Done saving initialized tensors +2020-08-09 09:37:41.563979 [I:onnxruntime:, inference_session.cc:919 Initialize] Session successfully initialized. +Number of inputs = 1 +Input 0 : name=data_0 +Input 0 : type=1 +Input 0 : num_dims=4 +Input 0 : dim 0=1 +Input 0 : dim 1=3 +Input 0 : dim 2=224 +Input 0 : dim 3=224 +2020-08-09 09:37:41.573127 [I:onnxruntime:, sequential_executor.cc:145 Execute] Begin execution +2020-08-09 09:37:41.573183 [I:onnxruntime:test, bfc_arena.cc:259 AllocateRawInternal] Extending BFCArena for Cpu. bin_num:13 rounded_bytes:3154176 +2020-08-09 09:37:41.573197 [I:onnxruntime:test, bfc_arena.cc:143 Extend] Extended allocation by 4194304 bytes. +2020-08-09 09:37:41.573203 [I:onnxruntime:test, bfc_arena.cc:147 Extend] Total allocated bytes: 9137152 +2020-08-09 09:37:41.573212 [I:onnxruntime:test, bfc_arena.cc:150 Extend] Allocated memory at 0x7fb7d6cb7000 to 0x7fb7d70b7000 +2020-08-09 09:37:41.573248 [I:onnxruntime:test, bfc_arena.cc:259 AllocateRawInternal] Extending BFCArena for Cpu. bin_num:8 rounded_bytes:65536 +2020-08-09 09:37:41.573256 [I:onnxruntime:test, bfc_arena.cc:143 Extend] Extended allocation by 4194304 bytes. +2020-08-09 09:37:41.573262 [I:onnxruntime:test, bfc_arena.cc:147 Extend] Total allocated bytes: 13331456 +2020-08-09 09:37:41.573268 [I:onnxruntime:test, bfc_arena.cc:150 Extend] Allocated memory at 0x7fb7d70b7000 to 0x7fb7d74b7000 +Score for class [0] = 0.000045440644 +Score for class [1] = 0.0038458651 +Score for class [2] = 0.00012494653 +Score for class [3] = 0.0011804523 +Score for class [4] = 0.0013169361 +Done! +``` + +### onnxruntime + +To run this example ([`onnxruntime/examples/sample.rs`](onnxruntime/examples/sample.rs)): + +```sh +# Download the model (SqueezeNet 1.0, ONNX version: 1.3, Opset version: 8) +❯ curl -LO "https://github.com/onnx/models/raw/main/vision/classification/squeezenet/model/squeezenet1.0-8.onnx" +❯ cargo run --example sample +[...] + Finished dev [unoptimized + debuginfo] target(s) in 13.62s + Running `target/debug/examples/sample` +Uninitialized environment found, initializing it with name "test". +2020-08-09 09:34:37.395577 [I:onnxruntime:, inference_session.cc:174 ConstructorCommon] Creating and using per session threadpools since use_per_session_threads_ is true +2020-08-09 09:34:37.399253 [I:onnxruntime:, inference_session.cc:830 Initialize] Initializing session. +2020-08-09 09:34:37.399284 [I:onnxruntime:, inference_session.cc:848 Initialize] Adding default CPU execution provider. +2020-08-09 09:34:37.399313 [I:onnxruntime:test, bfc_arena.cc:15 BFCArena] Creating BFCArena for Cpu +2020-08-09 09:34:37.399335 [V:onnxruntime:test, bfc_arena.cc:32 BFCArena] Creating 21 bins of max chunk size 256 to 268435456 +2020-08-09 09:34:37.410516 [I:onnxruntime:, reshape_fusion.cc:37 ApplyImpl] Total fused reshape node count: 0 +2020-08-09 09:34:37.417478 [I:onnxruntime:, reshape_fusion.cc:37 ApplyImpl] Total fused reshape node count: 0 +2020-08-09 09:34:37.420131 [I:onnxruntime:, reshape_fusion.cc:37 ApplyImpl] Total fused reshape node count: 0 +2020-08-09 09:34:37.422623 [V:onnxruntime:, inference_session.cc:671 TransformGraph] Node placements +2020-08-09 09:34:37.428863 [V:onnxruntime:, inference_session.cc:673 TransformGraph] All nodes have been placed on [CPUExecutionProvider]. +2020-08-09 09:34:37.428954 [I:onnxruntime:, session_state.cc:25 SetGraph] SaveMLValueNameIndexMapping +2020-08-09 09:34:37.429079 [I:onnxruntime:, session_state.cc:70 SetGraph] Done saving OrtValue mappings. +2020-08-09 09:34:37.429925 [I:onnxruntime:, session_state_initializer.cc:178 SaveInitializedTensors] Saving initialized tensors. +2020-08-09 09:34:37.436300 [I:onnxruntime:, session_state_initializer.cc:223 SaveInitializedTensors] Done saving initialized tensors +2020-08-09 09:34:37.437255 [I:onnxruntime:, inference_session.cc:919 Initialize] Session successfully initialized. +Dropping the session options. +2020-08-09 09:34:37.448956 [I:onnxruntime:, sequential_executor.cc:145 Execute] Begin execution +2020-08-09 09:34:37.449041 [I:onnxruntime:test, bfc_arena.cc:259 AllocateRawInternal] Extending BFCArena for Cpu. bin_num:13 rounded_bytes:3154176 +2020-08-09 09:34:37.449072 [I:onnxruntime:test, bfc_arena.cc:143 Extend] Extended allocation by 4194304 bytes. +2020-08-09 09:34:37.449087 [I:onnxruntime:test, bfc_arena.cc:147 Extend] Total allocated bytes: 9137152 +2020-08-09 09:34:37.449104 [I:onnxruntime:test, bfc_arena.cc:150 Extend] Allocated memory at 0x7fb3b9585000 to 0x7fb3b9985000 +2020-08-09 09:34:37.449176 [I:onnxruntime:test, bfc_arena.cc:259 AllocateRawInternal] Extending BFCArena for Cpu. bin_num:8 rounded_bytes:65536 +2020-08-09 09:34:37.449196 [I:onnxruntime:test, bfc_arena.cc:143 Extend] Extended allocation by 4194304 bytes. +2020-08-09 09:34:37.449209 [I:onnxruntime:test, bfc_arena.cc:147 Extend] Total allocated bytes: 13331456 +2020-08-09 09:34:37.449222 [I:onnxruntime:test, bfc_arena.cc:150 Extend] Allocated memory at 0x7fb3b9985000 to 0x7fb3b9d85000 +Dropping Tensor. +Score for class [0] = 0.000045440578 +Score for class [1] = 0.0038458686 +Score for class [2] = 0.0001249467 +Score for class [3] = 0.0011804511 +Score for class [4] = 0.00131694 +Dropping TensorFromOrt. +Dropping the session. +Dropping the memory information. +Dropping the environment. +``` + +See also the integration tests ([`onnxruntime/tests/integration_tests.rs`](onnxruntime/tests/integration_tests.rs)) +that performs simple model download and inference, validating the results. + +## License + +The Rust bindings are licensed under either of + +* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or + http://www.apache.org/licenses/LICENSE-2.0) +* MIT license ([LICENSE-MIT](LICENSE-MIT) or + http://opensource.org/licenses/MIT) + +at your option. diff --git a/rust/onnxruntime-sys/Cargo.toml b/rust/onnxruntime-sys/Cargo.toml new file mode 100644 index 0000000000000..4806e6ca2953c --- /dev/null +++ b/rust/onnxruntime-sys/Cargo.toml @@ -0,0 +1,35 @@ +[package] +authors = ["Nicolas Bigaouette "] +edition = "2018" +name = "onnxruntime-sys" +version = "0.0.14" + +links = "onnxruntime" + +description = "Unsafe wrapper around Microsoft's ONNX Runtime" +documentation = "https://docs.rs/onnxruntime-sys" +homepage = "https://github.com/microsoft/onnxruntime" +license = "MIT OR Apache-2.0" +readme = "../README.md" +repository = "https://github.com/microsoft/onnxruntime" + +categories = ["science"] +keywords = ["neuralnetworks", "onnx", "bindings"] + +[dependencies] +libloading = "0.7" + +[build-dependencies] +bindgen = "0.63" +cmake = "0.1" + +# Used on unix +flate2 = "1.0" +tar = "0.4" +ureq = "2.1" + +# Used on Windows +zip = "0.6" + +[features] +default = [] diff --git a/rust/onnxruntime-sys/build.rs b/rust/onnxruntime-sys/build.rs new file mode 100644 index 0000000000000..82d1e4278015c --- /dev/null +++ b/rust/onnxruntime-sys/build.rs @@ -0,0 +1,429 @@ +#![allow(dead_code)] + +use std::{ + borrow::Cow, + env, fs, + io::{self, Read, Write}, + path::{Path, PathBuf}, + str::FromStr, +}; + +/// ONNX Runtime version +/// +/// WARNING: If version is changed, bindings for all platforms will have to be re-generated. +/// To do so, run this: +/// cargo build --package onnxruntime-sys --features generate-bindings +const ORT_VERSION: &str = include_str!("../../VERSION_NUMBER"); + +/// Base Url from which to download pre-built releases/ +const ORT_RELEASE_BASE_URL: &str = "https://github.com/microsoft/onnxruntime/releases/download"; + +/// Environment variable selecting which strategy to use for finding the library +/// Possibilities: +/// * "download": Download a pre-built library. This is the default if `ORT_STRATEGY` is not set. +/// * "system": Use installed library. Use `ORT_LIB_LOCATION` to point to proper location. +/// * "compile": Download source and compile (TODO). +const ORT_RUST_ENV_STRATEGY: &str = "ORT_RUST_STRATEGY"; + +/// Name of environment variable that, if present, contains the location of a pre-built library. +/// Only used if `ORT_STRATEGY=system`. +const ORT_RUST_ENV_SYSTEM_LIB_LOCATION: &str = "ORT_RUST_LIB_LOCATION"; +/// Name of environment variable that, if present, controls whether to use CUDA or not. +const ORT_RUST_ENV_GPU: &str = "ORT_RUST_USE_CUDA"; + +/// Subdirectory (of the 'target' directory) into which to extract the prebuilt library. +const ORT_PREBUILT_EXTRACT_DIR: &str = "onnxruntime"; + +fn main() { + let libort_install_dir = prepare_libort_dir(); + + let include_dir = libort_install_dir.join("include"); + let lib_dir = libort_install_dir.join("lib"); + + println!("Include directory: {:?}", include_dir); + println!("Lib directory: {:?}", lib_dir); + + // Tell cargo to tell rustc to link onnxruntime shared library. + println!("cargo:rustc-link-lib=onnxruntime"); + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + + println!("cargo:rerun-if-env-changed={}", ORT_RUST_ENV_STRATEGY); + println!("cargo:rerun-if-env-changed={}", ORT_RUST_ENV_GPU); + println!( + "cargo:rerun-if-env-changed={}", + ORT_RUST_ENV_SYSTEM_LIB_LOCATION + ); + + generate_bindings(&include_dir); +} + +fn generate_bindings(include_dir: &Path) { + let clang_args = &[ + format!("-I{}", include_dir.display()), + format!( + "-I{}", + include_dir + .join("onnxruntime") + .join("core") + .join("session") + .display() + ), + ]; + + let path = include_dir + .join("onnxruntime") + .join("core") + .join("session") + .join("onnxruntime_c_api.h"); + + // The bindgen::Builder is the main entry point + // to bindgen, and lets you build up options for + // the resulting bindings. + let bindings = bindgen::Builder::default() + // The input header we would like to generate + // bindings for. + .header(path.to_string_lossy().to_string()) + // The current working directory is 'onnxruntime-sys' + .clang_args(clang_args) + // Tell cargo to invalidate the built crate whenever any of the + // included header files changed. + .parse_callbacks(Box::new(bindgen::CargoCallbacks)) + .dynamic_library_name("onnxruntime") + .allowlist_type("Ort.*") + .allowlist_type("Onnx.*") + .allowlist_type("ONNX.*") + .allowlist_function("Ort.*") + .allowlist_var("ORT.*") + // Set `size_t` to be translated to `usize` for win32 compatibility. + .size_t_is_usize(true) + // Format using rustfmt + .rustfmt_bindings(true) + .rustified_enum(".*") + // Finish the builder and generate the bindings. + .generate() + // Unwrap the Result and panic on failure. + .expect("Unable to generate bindings"); + + let generated_file = PathBuf::from(env::var("OUT_DIR").unwrap()).join("bindings.rs"); + println!("cargo:rerun-if-changed={:?}", generated_file); + bindings + .write_to_file(&generated_file) + .expect("Couldn't write bindings!"); +} + +fn download

(source_url: &str, target_file: P) +where + P: AsRef, +{ + let resp = ureq::get(source_url) + .timeout(std::time::Duration::from_secs(300)) + .call() + .unwrap_or_else(|err| panic!("ERROR: Failed to download {}: {:?}", source_url, err)); + + let len = resp + .header("Content-Length") + .and_then(|s| s.parse::().ok()) + .unwrap(); + let mut reader = resp.into_reader(); + // FIXME: Save directly to the file + let mut buffer = vec![]; + let read_len = reader.read_to_end(&mut buffer).unwrap(); + assert_eq!(buffer.len(), len); + assert_eq!(buffer.len(), read_len); + + let f = fs::File::create(&target_file).unwrap(); + let mut writer = io::BufWriter::new(f); + writer.write_all(&buffer).unwrap(); +} + +fn extract_archive(filename: &Path, output: &Path) { + match filename.extension().map(std::ffi::OsStr::to_str) { + Some(Some("zip")) => extract_zip(filename, output), + Some(Some("tgz")) => extract_tgz(filename, output), + _ => unimplemented!(), + } +} + +fn extract_tgz(filename: &Path, output: &Path) { + let file = fs::File::open(&filename).unwrap(); + let buf = io::BufReader::new(file); + let tar = flate2::read::GzDecoder::new(buf); + let mut archive = tar::Archive::new(tar); + archive.unpack(output).unwrap(); +} + +fn extract_zip(filename: &Path, outpath: &Path) { + let file = fs::File::open(&filename).unwrap(); + let buf = io::BufReader::new(file); + let mut archive = zip::ZipArchive::new(buf).unwrap(); + for i in 0..archive.len() { + let mut file = archive.by_index(i).unwrap(); + #[allow(deprecated)] + let outpath = outpath.join(file.sanitized_name()); + if !file.name().ends_with('/') { + println!( + "File {} extracted to \"{}\" ({} bytes)", + i, + outpath.as_path().display(), + file.size() + ); + if let Some(p) = outpath.parent() { + if !p.exists() { + fs::create_dir_all(&p).unwrap(); + } + } + let mut outfile = fs::File::create(&outpath).unwrap(); + io::copy(&mut file, &mut outfile).unwrap(); + } + } +} + +trait OnnxPrebuiltArchive { + fn as_onnx_str(&self) -> Cow; +} + +#[derive(Debug)] +enum Architecture { + X86, + X86_64, + Arm, + Arm64, +} + +impl FromStr for Architecture { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "x86" => Ok(Architecture::X86), + "x86_64" => Ok(Architecture::X86_64), + "arm" => Ok(Architecture::Arm), + "aarch64" => Ok(Architecture::Arm64), + _ => Err(format!("Unsupported architecture: {}", s)), + } + } +} + +impl OnnxPrebuiltArchive for Architecture { + fn as_onnx_str(&self) -> Cow { + match self { + Architecture::X86 => Cow::from("x86"), + Architecture::X86_64 => Cow::from("x64"), + Architecture::Arm => Cow::from("arm"), + Architecture::Arm64 => Cow::from("arm64"), + } + } +} + +#[derive(Debug)] +#[allow(clippy::enum_variant_names)] +enum Os { + Windows, + Linux, + MacOs, +} + +impl Os { + fn archive_extension(&self) -> &'static str { + match self { + Os::Windows => "zip", + Os::Linux => "tgz", + Os::MacOs => "tgz", + } + } +} + +impl FromStr for Os { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "windows" => Ok(Os::Windows), + "macos" => Ok(Os::MacOs), + "linux" => Ok(Os::Linux), + _ => Err(format!("Unsupported os: {}", s)), + } + } +} + +impl OnnxPrebuiltArchive for Os { + fn as_onnx_str(&self) -> Cow { + match self { + Os::Windows => Cow::from("win"), + Os::Linux => Cow::from("linux"), + Os::MacOs => Cow::from("osx"), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum Accelerator { + Cpu, + Cuda, +} + +impl FromStr for Accelerator { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "1" | "yes" | "true" | "on" => Ok(Accelerator::Cuda), + _ => Ok(Accelerator::Cpu), + } + } +} + +impl OnnxPrebuiltArchive for Accelerator { + fn as_onnx_str(&self) -> Cow { + match self { + Accelerator::Cpu => Cow::from(""), + Accelerator::Cuda => Cow::from("gpu"), + } + } +} + +#[derive(Debug)] +struct Triplet { + os: Os, + arch: Architecture, + accelerator: Accelerator, +} + +impl OnnxPrebuiltArchive for Triplet { + fn as_onnx_str(&self) -> Cow { + match (&self.os, &self.arch, &self.accelerator) { + // onnxruntime-win-x86-1.11.1.zip + // onnxruntime-win-x64-1.11.1.zip + // onnxruntime-win-arm-1.11.1.zip + // onnxruntime-win-arm64-1.11.1.zip + // onnxruntime-linux-x64-1.11.1.tgz + // onnxruntime-osx-x86_64-1.11.1.tgz + // onnxruntime-osx-arm64-1.11.1.tgz + ( + Os::Windows, + Architecture::X86 | Architecture::X86_64 | Architecture::Arm | Architecture::Arm64, + Accelerator::Cpu, + ) + | (Os::MacOs, Architecture::Arm64, Accelerator::Cpu) + | (Os::Linux, Architecture::X86_64, Accelerator::Cpu) => Cow::from(format!( + "{}-{}", + self.os.as_onnx_str(), + self.arch.as_onnx_str() + )), + (Os::MacOs, Architecture::X86_64, Accelerator::Cpu) => Cow::from(format!( + "{}-x86_{}", + self.os.as_onnx_str(), + self.arch.as_onnx_str().trim_start_matches('x') + )), + // onnxruntime-win-x64-gpu-1.11.1.zip + // onnxruntime-linux-x64-gpu-1.11.1.tgz + (Os::Linux | Os::Windows, Architecture::X86_64, Accelerator::Cuda) => { + Cow::from(format!( + "{}-{}-{}", + self.os.as_onnx_str(), + self.arch.as_onnx_str(), + self.accelerator.as_onnx_str(), + )) + } + _ => { + panic!( + "Unsupported prebuilt triplet: {:?}, {:?}, {:?}. Please use {}=system and {}=/path/to/onnxruntime", + self.os, self.arch, self.accelerator, ORT_RUST_ENV_STRATEGY, ORT_RUST_ENV_SYSTEM_LIB_LOCATION + ); + } + } + } +} + +fn prebuilt_archive_url() -> (PathBuf, String) { + let triplet = Triplet { + os: env::var("CARGO_CFG_TARGET_OS") + .expect("Unable to get TARGET_OS") + .parse() + .unwrap(), + arch: env::var("CARGO_CFG_TARGET_ARCH") + .expect("Unable to get TARGET_ARCH") + .parse() + .unwrap(), + accelerator: env::var(ORT_RUST_ENV_GPU) + .unwrap_or_default() + .parse() + .unwrap(), + }; + + let prebuilt_archive = format!( + "onnxruntime-{}-{}.{}", + triplet.as_onnx_str(), + ORT_VERSION, + triplet.os.archive_extension() + ); + let prebuilt_url = format!( + "{}/v{}/{}", + ORT_RELEASE_BASE_URL, ORT_VERSION, prebuilt_archive + ); + + (PathBuf::from(prebuilt_archive), prebuilt_url) +} + +fn prepare_libort_dir_prebuilt() -> PathBuf { + let (prebuilt_archive, prebuilt_url) = prebuilt_archive_url(); + + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let extract_dir = out_dir.join(ORT_PREBUILT_EXTRACT_DIR); + let downloaded_file = out_dir.join(&prebuilt_archive); + + println!("cargo:rerun-if-changed={}", downloaded_file.display()); + + if !downloaded_file.exists() { + println!("Creating directory {:?}", out_dir); + fs::create_dir_all(&out_dir).unwrap(); + + println!( + "Downloading {} into {}", + prebuilt_url, + downloaded_file.display() + ); + download(&prebuilt_url, &downloaded_file); + } + + if !extract_dir.exists() { + println!("Extracting to {}...", extract_dir.display()); + extract_archive(&downloaded_file, &extract_dir); + } + + extract_dir.join(prebuilt_archive.file_stem().unwrap()) +} + +fn prepare_libort_dir() -> PathBuf { + let strategy = env::var(ORT_RUST_ENV_STRATEGY); + println!( + "strategy: {:?}", + strategy.as_ref().map_or_else(|_| "unknown", String::as_str) + ); + match strategy.as_ref().map(String::as_str) { + Ok("download") => prepare_libort_dir_prebuilt(), + Ok("system") => PathBuf::from(match env::var(ORT_RUST_ENV_SYSTEM_LIB_LOCATION) { + Ok(p) => p, + Err(e) => { + panic!( + "Could not get value of environment variable {:?}: {:?}", + ORT_RUST_ENV_SYSTEM_LIB_LOCATION, e + ); + } + }), + Ok("compile") | Err(_) => prepare_libort_dir_compiled(), + _ => panic!("Unknown value for {:?}", ORT_RUST_ENV_STRATEGY), + } +} + +fn prepare_libort_dir_compiled() -> PathBuf { + let mut config = cmake::Config::new("../../cmake"); + + config.define("onnxruntime_BUILD_SHARED_LIB", "ON"); + + if env::var(ORT_RUST_ENV_GPU).unwrap_or_default().parse() == Ok(Accelerator::Cuda) { + config.define("onnxruntime_USE_CUDA", "ON"); + } + + config.build() +} diff --git a/rust/onnxruntime-sys/examples/c_api_sample.rs b/rust/onnxruntime-sys/examples/c_api_sample.rs new file mode 100644 index 0000000000000..499f1548de396 --- /dev/null +++ b/rust/onnxruntime-sys/examples/c_api_sample.rs @@ -0,0 +1,395 @@ +#![allow(non_snake_case)] + +use std::env::args; +#[cfg(not(target_family = "windows"))] +use std::os::unix::ffi::OsStrExt; +#[cfg(target_family = "windows")] +use std::os::windows::ffi::OsStrExt; + +use onnxruntime_sys::{ + onnxruntime, GraphOptimizationLevel, ONNXTensorElementDataType, OrtAllocator, OrtAllocatorType, + OrtApi, OrtEnv, OrtLoggingLevel, OrtMemType, OrtMemoryInfo, OrtRunOptions, OrtSession, + OrtSessionOptions, OrtStatus, OrtTensorTypeAndShapeInfo, OrtTypeInfo, OrtValue, + ORT_API_VERSION, +}; + +// https://github.com/microsoft/onnxruntime/blob/v1.4.0/csharp/test/Microsoft.ML.OnnxRuntime.EndToEndTests.Capi/C_Api_Sample.cpp + +fn main() { + let onnxruntime_path = args() + .nth(1) + .expect("This example expects a path to the ONNXRuntime shared library"); + + let (_, g_ort) = unsafe { + let ort = onnxruntime::new(onnxruntime_path); + + let ort = ort.expect("Error initializing onnxruntime"); + let g_ort = ort.OrtGetApiBase().as_ref().unwrap().GetApi.unwrap()(ORT_API_VERSION); + + (ort, g_ort) + }; + assert_ne!(g_ort, std::ptr::null_mut()); + + //************************************************************************* + // initialize enviroment...one enviroment per process + // enviroment maintains thread pools and other state info + let mut env_ptr: *mut OrtEnv = std::ptr::null_mut(); + let env_name = std::ffi::CString::new("test").unwrap(); + let status = unsafe { + g_ort.as_ref().unwrap().CreateEnv.unwrap()( + OrtLoggingLevel::ORT_LOGGING_LEVEL_VERBOSE, + env_name.as_ptr(), + &mut env_ptr, + ) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(env_ptr, std::ptr::null_mut()); + + // initialize session options if needed + let mut session_options_ptr: *mut OrtSessionOptions = std::ptr::null_mut(); + let status = + unsafe { g_ort.as_ref().unwrap().CreateSessionOptions.unwrap()(&mut session_options_ptr) }; + CheckStatus(g_ort, status).unwrap(); + unsafe { g_ort.as_ref().unwrap().SetIntraOpNumThreads.unwrap()(session_options_ptr, 1) }; + assert_ne!(session_options_ptr, std::ptr::null_mut()); + + // Sets graph optimization level + unsafe { + g_ort + .as_ref() + .unwrap() + .SetSessionGraphOptimizationLevel + .unwrap()( + session_options_ptr, + GraphOptimizationLevel::ORT_ENABLE_BASIC, + ) + }; + + // Optionally add more execution providers via session_options + // E.g. for CUDA include cuda_provider_factory.h and uncomment the following line: + // OrtSessionOptionsAppendExecutionProvider_CUDA(sessionOptions, 0); + + //************************************************************************* + // create session and load model into memory + // NOTE: Original C version loaded SqueezeNet 1.0 (ONNX version: 1.3, Opset version: 8, + // https://github.com/onnx/models/blob/main/vision/classification/squeezenet/model/squeezenet1.0-8.onnx) + // Download it: + // curl -LO "https://github.com/onnx/models/raw/main/vision/classification/squeezenet/model/squeezenet1.0-8.onnx" + // Reference: https://github.com/onnx/models/tree/main/vision/classification/squeezenet#model + let model_path = std::ffi::OsString::from("squeezenet1.0-8.onnx"); + + #[cfg(target_family = "windows")] + let model_path: Vec = model_path + .encode_wide() + .chain(std::iter::once(0)) // Make sure we have a null terminated string + .collect(); + #[cfg(not(target_family = "windows"))] + let model_path: Vec = model_path + .as_bytes() + .iter() + .chain(std::iter::once(&b'\0')) // Make sure we have a null terminated string + .map(|b| *b as std::os::raw::c_char) + .collect(); + + let mut session_ptr: *mut OrtSession = std::ptr::null_mut(); + + println!("Using Onnxruntime C API"); + let status = unsafe { + g_ort.as_ref().unwrap().CreateSession.unwrap()( + env_ptr, + model_path.as_ptr(), + session_options_ptr, + &mut session_ptr, + ) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(session_ptr, std::ptr::null_mut()); + + //************************************************************************* + // print model input layer (node names, types, shape etc.) + // size_t num_input_nodes; + let mut allocator_ptr: *mut OrtAllocator = std::ptr::null_mut(); + let status = unsafe { + g_ort + .as_ref() + .unwrap() + .GetAllocatorWithDefaultOptions + .unwrap()(&mut allocator_ptr) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(allocator_ptr, std::ptr::null_mut()); + + // print number of model input nodes + let mut num_input_nodes: usize = 0; + let status = unsafe { + g_ort.as_ref().unwrap().SessionGetInputCount.unwrap()(session_ptr, &mut num_input_nodes) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(num_input_nodes, 0); + println!("Number of inputs = {:?}", num_input_nodes); + let mut input_node_names: Vec<&str> = Vec::new(); + let mut input_node_dims: Vec = Vec::new(); // simplify... this model has only 1 input node {1, 3, 224, 224}. + // Otherwise need vector> + + // iterate over all input nodes + for i in 0..num_input_nodes { + // print input node names + let mut input_name: *mut i8 = std::ptr::null_mut(); + let status = unsafe { + g_ort.as_ref().unwrap().SessionGetInputName.unwrap()( + session_ptr, + i, + allocator_ptr, + &mut input_name, + ) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(input_name, std::ptr::null_mut()); + + // WARNING: The C function SessionGetInputName allocates memory for the string. + // We cannot let Rust free that string, the C side must free the string. + // We thus convert the pointer to a string slice (&str). + let input_name = char_p_to_str(input_name).unwrap(); + println!("Input {} : name={}", i, input_name); + input_node_names.push(input_name); + + // print input node types + let mut typeinfo_ptr: *mut OrtTypeInfo = std::ptr::null_mut(); + let status = unsafe { + g_ort.as_ref().unwrap().SessionGetInputTypeInfo.unwrap()( + session_ptr, + i, + &mut typeinfo_ptr, + ) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(typeinfo_ptr, std::ptr::null_mut()); + + let mut tensor_info_ptr: *const OrtTensorTypeAndShapeInfo = std::ptr::null_mut(); + let status = unsafe { + g_ort.as_ref().unwrap().CastTypeInfoToTensorInfo.unwrap()( + typeinfo_ptr, + &mut tensor_info_ptr, + ) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(tensor_info_ptr, std::ptr::null_mut()); + + let mut type_: ONNXTensorElementDataType = + ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED; + let status = unsafe { + g_ort.as_ref().unwrap().GetTensorElementType.unwrap()(tensor_info_ptr, &mut type_) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!( + type_, + ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED + ); + + println!("Input {} : type={}", i, type_ as i32); + + // print input shapes/dims + let mut num_dims = 0; + let status = unsafe { + g_ort.as_ref().unwrap().GetDimensionsCount.unwrap()(tensor_info_ptr, &mut num_dims) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(num_dims, 0); + + println!("Input {} : num_dims={}", i, num_dims); + input_node_dims.resize_with(num_dims as usize, Default::default); + let status = unsafe { + g_ort.as_ref().unwrap().GetDimensions.unwrap()( + tensor_info_ptr, + input_node_dims.as_mut_ptr(), + num_dims, + ) + }; + CheckStatus(g_ort, status).unwrap(); + + for j in 0..num_dims { + println!("Input {} : dim {}={}", i, j, input_node_dims[j as usize]); + } + + unsafe { g_ort.as_ref().unwrap().ReleaseTypeInfo.unwrap()(typeinfo_ptr) }; + } + + // Results should be... + // Number of inputs = 1 + // Input 0 : name = data_0 + // Input 0 : type = 1 + // Input 0 : num_dims = 4 + // Input 0 : dim 0 = 1 + // Input 0 : dim 1 = 3 + // Input 0 : dim 2 = 224 + // Input 0 : dim 3 = 224 + + //************************************************************************* + // Similar operations to get output node information. + // Use OrtSessionGetOutputCount(), OrtSessionGetOutputName() + // OrtSessionGetOutputTypeInfo() as shown above. + + //************************************************************************* + // Score the model using sample data, and inspect values + + let input_tensor_size = 224 * 224 * 3; // simplify ... using known dim values to calculate size + // use OrtGetTensorShapeElementCount() to get official size! + + let output_node_names = &["softmaxout_1"]; + + // initialize input data with values in [0.0, 1.0] + let mut input_tensor_values: Vec = (0..input_tensor_size) + .map(|i| (i as f32) / ((input_tensor_size + 1) as f32)) + .collect(); + + // create input tensor object from data values + let mut memory_info_ptr: *mut OrtMemoryInfo = std::ptr::null_mut(); + let status = unsafe { + g_ort.as_ref().unwrap().CreateCpuMemoryInfo.unwrap()( + OrtAllocatorType::OrtArenaAllocator, + OrtMemType::OrtMemTypeDefault, + &mut memory_info_ptr, + ) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(memory_info_ptr, std::ptr::null_mut()); + + // FIXME: Check me! + let mut input_tensor_ptr: *mut OrtValue = std::ptr::null_mut(); + let input_tensor_ptr_ptr: *mut *mut OrtValue = &mut input_tensor_ptr; + let input_tensor_values_ptr: *mut std::ffi::c_void = + input_tensor_values.as_mut_ptr().cast::(); + assert_ne!(input_tensor_values_ptr, std::ptr::null_mut()); + + let shape: *const i64 = input_node_dims.as_ptr(); + assert_ne!(shape, std::ptr::null_mut()); + + let status = unsafe { + g_ort + .as_ref() + .unwrap() + .CreateTensorWithDataAsOrtValue + .unwrap()( + memory_info_ptr, + input_tensor_values_ptr, + input_tensor_size * std::mem::size_of::(), + shape, + 4, + ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT, + input_tensor_ptr_ptr, + ) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(input_tensor_ptr, std::ptr::null_mut()); + + let mut is_tensor = 0; + let status = + unsafe { g_ort.as_ref().unwrap().IsTensor.unwrap()(input_tensor_ptr, &mut is_tensor) }; + CheckStatus(g_ort, status).unwrap(); + assert_eq!(is_tensor, 1); + + let input_tensor_ptr2: *const OrtValue = input_tensor_ptr as *const OrtValue; + let input_tensor_ptr3: *const *const OrtValue = &input_tensor_ptr2; + + unsafe { g_ort.as_ref().unwrap().ReleaseMemoryInfo.unwrap()(memory_info_ptr) }; + + // score model & input tensor, get back output tensor + + let input_node_names_cstring: Vec = input_node_names + .into_iter() + .map(|n| std::ffi::CString::new(n).unwrap()) + .collect(); + let input_node_names_ptr: Vec<*const i8> = input_node_names_cstring + .into_iter() + .map(|n| n.into_raw() as *const i8) + .collect(); + let input_node_names_ptr_ptr: *const *const i8 = input_node_names_ptr.as_ptr(); + + let output_node_names_cstring: Vec = output_node_names + .iter() + .map(|n| std::ffi::CString::new(n.clone()).unwrap()) + .collect(); + let output_node_names_ptr: Vec<*const i8> = output_node_names_cstring + .iter() + .map(|n| n.as_ptr().cast::()) + .collect(); + let output_node_names_ptr_ptr: *const *const i8 = output_node_names_ptr.as_ptr(); + + let _input_node_names_cstring = + unsafe { std::ffi::CString::from_raw(input_node_names_ptr[0] as *mut i8) }; + let run_options_ptr: *const OrtRunOptions = std::ptr::null(); + let mut output_tensor_ptr: *mut OrtValue = std::ptr::null_mut(); + let output_tensor_ptr_ptr: *mut *mut OrtValue = &mut output_tensor_ptr; + + let status = unsafe { + g_ort.as_ref().unwrap().Run.unwrap()( + session_ptr, + run_options_ptr, + input_node_names_ptr_ptr, + input_tensor_ptr3, + 1, + output_node_names_ptr_ptr, + 1, + output_tensor_ptr_ptr, + ) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(output_tensor_ptr, std::ptr::null_mut()); + + let mut is_tensor = 0; + let status = + unsafe { g_ort.as_ref().unwrap().IsTensor.unwrap()(output_tensor_ptr, &mut is_tensor) }; + CheckStatus(g_ort, status).unwrap(); + assert_eq!(is_tensor, 1); + + // Get pointer to output tensor float values + let mut floatarr: *mut f32 = std::ptr::null_mut(); + let floatarr_ptr: *mut *mut f32 = &mut floatarr; + let floatarr_ptr_void: *mut *mut std::ffi::c_void = + floatarr_ptr.cast::<*mut std::ffi::c_void>(); + let status = unsafe { + g_ort.as_ref().unwrap().GetTensorMutableData.unwrap()(output_tensor_ptr, floatarr_ptr_void) + }; + CheckStatus(g_ort, status).unwrap(); + assert_ne!(floatarr, std::ptr::null_mut()); + + assert!((unsafe { *floatarr.offset(0) } - 0.000_045).abs() < 1e-6); + + // score the model, and print scores for first 5 classes + // NOTE: The C ONNX Runtime allocated the array, we shouldn't drop the vec + // but let C de-allocate instead. + let floatarr_vec: Vec = unsafe { Vec::from_raw_parts(floatarr, 5, 5) }; + for i in 0..5 { + println!("Score for class [{}] = {}", i, floatarr_vec[i]); + } + std::mem::forget(floatarr_vec); + + // Results should be as below... + // Score for class[0] = 0.000045 + // Score for class[1] = 0.003846 + // Score for class[2] = 0.000125 + // Score for class[3] = 0.001180 + // Score for class[4] = 0.001317 + + unsafe { g_ort.as_ref().unwrap().ReleaseValue.unwrap()(output_tensor_ptr) }; + unsafe { g_ort.as_ref().unwrap().ReleaseValue.unwrap()(input_tensor_ptr) }; + unsafe { g_ort.as_ref().unwrap().ReleaseSession.unwrap()(session_ptr) }; + unsafe { g_ort.as_ref().unwrap().ReleaseSessionOptions.unwrap()(session_options_ptr) }; + unsafe { g_ort.as_ref().unwrap().ReleaseEnv.unwrap()(env_ptr) }; + + println!("Done!"); +} + +fn CheckStatus(g_ort: *const OrtApi, status: *const OrtStatus) -> Result<(), String> { + if status != std::ptr::null() { + let raw = unsafe { g_ort.as_ref().unwrap().GetErrorMessage.unwrap()(status) }; + Err(char_p_to_str(raw).unwrap().to_string()) + } else { + Ok(()) + } +} + +fn char_p_to_str<'a>(raw: *const i8) -> Result<&'a str, std::str::Utf8Error> { + let c_str = unsafe { std::ffi::CStr::from_ptr(raw as *mut i8) }; + c_str.to_str() +} diff --git a/rust/onnxruntime-sys/src/lib.rs b/rust/onnxruntime-sys/src/lib.rs new file mode 100644 index 0000000000000..c1ba5c347a036 --- /dev/null +++ b/rust/onnxruntime-sys/src/lib.rs @@ -0,0 +1,15 @@ +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +// Disable clippy and `u128` not being FFI-safe (see #1) +#![allow(clippy::all)] +#![allow(improper_ctypes)] + +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); + +#[cfg(target_os = "windows")] +pub type OnnxEnumInt = i32; +#[cfg(not(target_os = "windows"))] +pub type OnnxEnumInt = u32; + +pub use libloading::library_filename; diff --git a/rust/onnxruntime/Cargo.toml b/rust/onnxruntime/Cargo.toml new file mode 100644 index 0000000000000..d52904c5e50a0 --- /dev/null +++ b/rust/onnxruntime/Cargo.toml @@ -0,0 +1,43 @@ +[package] +authors = ["Nicolas Bigaouette "] +edition = "2018" +name = "onnxruntime" +version = "0.0.14" + +description = "Wrapper around Microsoft's ONNX Runtime" +documentation = "https://docs.rs/onnxruntime" +homepage = "https://onnxruntime.ai/" +license = "MIT OR Apache-2.0" +readme = "../README.md" +repository = "https://github.com/microsoft/onnxruntime" + +categories = ["science"] +keywords = ["neuralnetworks", "onnx", "bindings"] + +[[test]] +name = "integration_tests" +required-features = ["model-fetching"] + +[dependencies] +libloading = "0.7" +ndarray = "0.15" +once_cell = "1.17" +onnxruntime-sys = { version = "0.0.14", path = "../onnxruntime-sys" } +thiserror = "1.0" +tracing = "0.1" + +# Enabled with 'model-fetching' feature +ureq = { version = "2.1", optional = true } + +[dev-dependencies] +image = "0.24" +test-log = { version = "0.2", default-features = false, features = ["trace"] } +tracing-subscriber = "0.2" +ureq = "2.1" + +[features] +# Fetch model from ONNX Model Zoo (https://github.com/onnx/models) +model-fetching = ["ureq"] + +[package.metadata.docs.rs] +features = ["model-fetching"] diff --git a/rust/onnxruntime/examples/issue22.rs b/rust/onnxruntime/examples/issue22.rs new file mode 100644 index 0000000000000..6c96e899fa774 --- /dev/null +++ b/rust/onnxruntime/examples/issue22.rs @@ -0,0 +1,55 @@ +//! Example reproducing issue #22. +//! +//! `model.onnx` available to download here: +//! https://drive.google.com/file/d/1FmL-Wpm06V-8wgRqvV3Skey_X98Ue4D_/view?usp=sharing + +use ndarray::Array2; +use onnxruntime::{environment::Environment, GraphOptimizationLevel, LoggingLevel}; +use std::env::var; +use tracing::Level; +use tracing_subscriber::FmtSubscriber; + +fn main() { + // a builder for `FmtSubscriber`. + let subscriber = FmtSubscriber::builder() + // all spans/events with a level higher than TRACE (e.g, debug, info, warn, etc.) + // will be written to stdout. + .with_max_level(Level::TRACE) + // completes the builder. + .finish(); + + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + let path = var("RUST_ONNXRUNTIME_LIBRARY_PATH").ok(); + + let builder = Environment::builder() + .with_name("env") + .with_log_level(LoggingLevel::Warning); + + let builder = if let Some(path) = path.clone() { + builder.with_library_path(path) + } else { + builder + }; + + let env = builder.build().unwrap(); + let session = env + .new_session_builder() + .unwrap() + .with_graph_optimization_level(GraphOptimizationLevel::Basic) + .unwrap() + .with_model_from_file("model.onnx") + .unwrap(); + + println!("{:#?}", session.inputs); + println!("{:#?}", session.outputs); + + let input_ids = Array2::::from_shape_vec((1, 3), vec![1, 2, 3]).unwrap(); + let attention_mask = Array2::::from_shape_vec((1, 3), vec![1, 1, 1]).unwrap(); + + let inputs = vec![input_ids.into(), attention_mask.into()]; + + let outputs = session.run(inputs).unwrap(); + + print!("outputs: {:#?}", outputs[0].float_array().unwrap()); +} diff --git a/rust/onnxruntime/examples/print_structure.rs b/rust/onnxruntime/examples/print_structure.rs new file mode 100644 index 0000000000000..ce38218189616 --- /dev/null +++ b/rust/onnxruntime/examples/print_structure.rs @@ -0,0 +1,47 @@ +//! Display the input and output structure of an ONNX model. +use onnxruntime::{environment, LoggingLevel}; +use std::{env::var, error::Error}; + +fn main() -> Result<(), Box> { + let path = var("RUST_ONNXRUNTIME_LIBRARY_PATH").ok(); + + let builder = environment::Environment::builder() + .with_name("onnx_metadata") + .with_log_level(LoggingLevel::Verbose); + + let builder = if let Some(path) = path.clone() { + builder.with_library_path(path) + } else { + builder + }; + + let environment = builder.build().unwrap(); + + // provide path to .onnx model on disk + let path = std::env::args() + .nth(1) + .expect("Must provide an .onnx file as the first arg"); + + let session = environment + .new_session_builder()? + .with_graph_optimization_level(onnxruntime::GraphOptimizationLevel::Basic)? + .with_model_from_file(path)?; + + println!("Inputs:"); + for (index, input) in session.inputs.iter().enumerate() { + println!( + " {}:\n name = {}\n type = {:?}\n dimensions = {:?}", + index, input.name, input.input_type, input.dimensions + ) + } + + println!("Outputs:"); + for (index, output) in session.outputs.iter().enumerate() { + println!( + " {}:\n name = {}\n type = {:?}\n dimensions = {:?}", + index, output.name, output.output_type, output.dimensions + ); + } + + Ok(()) +} diff --git a/rust/onnxruntime/examples/sample.rs b/rust/onnxruntime/examples/sample.rs new file mode 100644 index 0000000000000..9af5cf733ccae --- /dev/null +++ b/rust/onnxruntime/examples/sample.rs @@ -0,0 +1,83 @@ +#![forbid(unsafe_code)] + +use onnxruntime::{environment::Environment, ndarray::Array, GraphOptimizationLevel, LoggingLevel}; +use std::env::var; +use tracing::Level; +use tracing_subscriber::FmtSubscriber; + +type Error = Box; + +fn main() { + if let Err(e) = run() { + eprintln!("Error: {}", e); + std::process::exit(1); + } +} + +fn run() -> Result<(), Error> { + // Setup the example's log level. + // NOTE: ONNX Runtime's log level is controlled separately when building the environment. + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::TRACE) + .finish(); + + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + let path = var("RUST_ONNXRUNTIME_LIBRARY_PATH").ok(); + + let builder = Environment::builder() + .with_name("test") + .with_log_level(LoggingLevel::Warning); + + let builder = if let Some(path) = path.clone() { + builder.with_library_path(path) + } else { + builder + }; + + let environment = builder.build().unwrap(); + + let session = environment + .new_session_builder()? + .with_graph_optimization_level(GraphOptimizationLevel::Basic)? + .with_intra_op_num_threads(1)? + // NOTE: The example uses SqueezeNet 1.0 (ONNX version: 1.3, Opset version: 8), + // _not_ SqueezeNet 1.1 as downloaded by '.with_model_downloaded(ImageClassification::SqueezeNet)' + // Obtain it with: + // curl -LO "https://github.com/onnx/models/raw/main/vision/classification/squeezenet/model/squeezenet1.0-8.onnx" + .with_model_from_file("squeezenet1.0-8.onnx")?; + + let input0_shape: Vec = session.inputs[0] + .dimensions() + .map(std::option::Option::unwrap) + .collect(); + let output0_shape: Vec = session.outputs[0] + .dimensions() + .map(std::option::Option::unwrap) + .collect(); + + assert_eq!(input0_shape, [1, 3, 224, 224]); + assert_eq!(output0_shape, [1, 1000, 1, 1]); + + // initialize input data with values in [0.0, 1.0] + let n: u32 = session.inputs[0] + .dimensions + .iter() + .map(|d| d.unwrap()) + .product(); + let array = Array::linspace(0.0_f32, 1.0, n as usize) + .into_shape(input0_shape) + .unwrap(); + let input_tensor_values = vec![array.into()]; + + let outputs = session.run(input_tensor_values)?; + + let output = outputs[0].float_array().unwrap(); + + assert_eq!(output.shape(), output0_shape.as_slice()); + for i in 0..5 { + println!("Score for class [{}] = {}", i, output[[0, i, 0, 0]]); + } + + Ok(()) +} diff --git a/rust/onnxruntime/src/download.rs b/rust/onnxruntime/src/download.rs new file mode 100644 index 0000000000000..0b600f3786ada --- /dev/null +++ b/rust/onnxruntime/src/download.rs @@ -0,0 +1,113 @@ +//! Module controlling models downloadable from ONNX Model Zoom +//! +//! Pre-trained models are available from the +//! [ONNX Model Zoo](https://github.com/onnx/models). +//! +//! A pre-trained model can be downloaded automatically using the +//! [`SessionBuilder`](../session/struct.SessionBuilder.html)'s +//! [`with_model_downloaded()`](../session/struct.SessionBuilder.html#method.with_model_downloaded) method. +//! +//! See [`AvailableOnnxModel`](enum.AvailableOnnxModel.html) for the different models available +//! to download. + +#[cfg(feature = "model-fetching")] +use std::{ + fs, io, + path::{Path, PathBuf}, + time::Duration, +}; + +#[cfg(feature = "model-fetching")] +use crate::error::{OrtDownloadError, Result}; + +#[cfg(feature = "model-fetching")] +use tracing::info; + +pub mod language; +pub mod vision; + +/// Available pre-trained models to download from [ONNX Model Zoo](https://github.com/onnx/models). +/// +/// According to [ONNX Model Zoo](https://github.com/onnx/models)'s GitHub page: +/// +/// > The ONNX Model Zoo is a collection of pre-trained, state-of-the-art models in the ONNX format +/// > contributed by community members like you. +#[derive(Debug, Clone)] +pub enum AvailableOnnxModel { + /// Computer vision model + Vision(vision::Vision), + /// Natural language model + Language(language::Language), +} + +trait ModelUrl { + fn fetch_url(&self) -> &'static str; +} + +impl ModelUrl for AvailableOnnxModel { + fn fetch_url(&self) -> &'static str { + match self { + AvailableOnnxModel::Vision(model) => model.fetch_url(), + AvailableOnnxModel::Language(model) => model.fetch_url(), + } + } +} + +impl AvailableOnnxModel { + #[cfg(feature = "model-fetching")] + #[tracing::instrument] + pub(crate) fn download_to

(&self, download_dir: P) -> Result + where + P: AsRef + std::fmt::Debug, + { + let url = self.fetch_url(); + + let model_filename = PathBuf::from(url.split('/').last().unwrap()); + let model_filepath = download_dir.as_ref().join(model_filename); + + if model_filepath.exists() { + info!( + model_filepath = format!("{}", model_filepath.display()).as_str(), + "File already exists, not re-downloading.", + ); + Ok(model_filepath) + } else { + info!( + model_filepath = format!("{}", model_filepath.display()).as_str(), + url = format!("{:?}", url).as_str(), + "Downloading file, please wait....", + ); + + let resp = ureq::get(url) + .timeout(Duration::from_secs(180)) // 3 minutes + .call() + .map_err(Box::new) + .map_err(OrtDownloadError::UreqError)?; + + assert!(resp.has("Content-Length")); + let len = resp + .header("Content-Length") + .and_then(|s| s.parse::().ok()) + .unwrap(); + info!(len, "Downloading {} bytes...", len); + + let mut reader = resp.into_reader(); + + let f = fs::File::create(&model_filepath).unwrap(); + let mut writer = io::BufWriter::new(f); + + let bytes_io_count = + io::copy(&mut reader, &mut writer).map_err(OrtDownloadError::IoError)?; + + if bytes_io_count == len as u64 { + Ok(model_filepath) + } else { + Err(OrtDownloadError::CopyError { + expected: len as u64, + io: bytes_io_count, + } + .into()) + } + } + } +} diff --git a/rust/onnxruntime/src/download/language.rs b/rust/onnxruntime/src/download/language.rs new file mode 100644 index 0000000000000..9bf068cf379ef --- /dev/null +++ b/rust/onnxruntime/src/download/language.rs @@ -0,0 +1,25 @@ +//! Module defining natural language models available to download. +//! +//! See [https://github.com/onnx/models#machine_comprehension](https://github.com/onnx/models#machine_comprehension). + +use super::ModelUrl; + +pub mod machine_comprehension; + +// Re-exports +pub use machine_comprehension::MachineComprehension; + +/// Natural language models +#[derive(Debug, Clone)] +pub enum Language { + /// Machine comprehension + MachineComprehension(MachineComprehension), +} + +impl ModelUrl for Language { + fn fetch_url(&self) -> &'static str { + match self { + Language::MachineComprehension(variant) => variant.fetch_url(), + } + } +} diff --git a/rust/onnxruntime/src/download/language/machine_comprehension.rs b/rust/onnxruntime/src/download/language/machine_comprehension.rs new file mode 100644 index 0000000000000..76143aacd8b35 --- /dev/null +++ b/rust/onnxruntime/src/download/language/machine_comprehension.rs @@ -0,0 +1,127 @@ +//! Module defining machine comprehension models available to download. +//! +//! See [https://github.com/onnx/models#machine_comprehension](https://github.com/onnx/models#machine_comprehension) + +// Acronyms are specific ONNX model names and contains upper cases +#![allow(clippy::upper_case_acronyms)] + +use crate::download::{language::Language, AvailableOnnxModel, ModelUrl}; + +/// Machine Comprehension +/// +/// > This subset of natural language processing models that answer questions about a given context paragraph. +/// +/// Source: [https://github.com/onnx/models#machine_comprehension](https://github.com/onnx/models#machine_comprehension) +#[derive(Debug, Clone)] +pub enum MachineComprehension { + /// Answers a query about a given context paragraph. + /// + /// > This model is a neural network for answering a query about a given context paragraph. + /// + /// Source: [https://github.com/onnx/models/tree/main/text/machine_comprehension/bidirectional_attention_flow](https://github.com/onnx/models/tree/main/text/machine_comprehension/bidirectional_attention_flow) + /// + /// Variant downloaded: ONNX Version 1.4 with Opset Version 9. + BiDAF, + /// Answers questions based on the context of the given input paragraph. + /// + /// Source: [https://github.com/onnx/models/tree/main/text/machine_comprehension/bert-squad](https://github.com/onnx/models/tree/main/text/machine_comprehension/bert-squad) + /// + /// Variant downloaded: ONNX Version 1.5 with Opset Version 10. + BERTSquad, + /// Large transformer-based model that predicts sentiment based on given input text. + /// + /// > Transformer-based language model for text generation. + /// + /// Source: [https://github.com/onnx/models/tree/main/text/machine_comprehension/roberta](https://github.com/onnx/models/tree/main/text/machine_comprehension/roberta) + RoBERTa(RoBERTa), + /// Large transformer-based language model that given a sequence of words within some text, predicts the next word. + /// + /// Source: [https://github.com/onnx/models/tree/main/text/machine_comprehension/gpt-2](https://github.com/onnx/models/tree/main/text/machine_comprehension/gpt-2) + GPT2(GPT2), +} + +/// Large transformer-based model that predicts sentiment based on given input text. +/// +/// > Transformer-based language model for text generation. +/// +/// Source: [https://github.com/onnx/models/tree/main/text/machine_comprehension/roberta](https://github.com/onnx/models/tree/main/text/machine_comprehension/roberta) +#[derive(Debug, Clone)] +pub enum RoBERTa { + /// Variant with input is a sequence of words as a string. Example: "Text to encode: Hello, World" + /// + /// Variant downloaded: ONNX Version 1.6 with Opset Version 11. + RoBERTaBase, + /// Variant with input is a sequence of words as a string including sentiment. Example: "This film is so good" + /// + /// Variant downloaded: ONNX Version 1.6 with Opset Version 9. + RoBERTaSequenceClassification, +} + +/// Large transformer-based language model that given a sequence of words within some text, predicts the next word. +/// +/// > Transformer-based language model for text generation. +/// +/// Source: [https://github.com/onnx/models/tree/main/text/machine_comprehension/gpt-2](https://github.com/onnx/models/tree/main/text/machine_comprehension/gpt-2) +/// +/// Variant downloaded: ONNX Version 1.6 with Opset Version 10. +#[derive(Debug, Clone)] +pub enum GPT2 { + /// Pure GPT2 + GPT2, + /// GPT2 + script changes + /// + /// See [https://github.com/onnx/models/blob/main/text/machine_comprehension/gpt-2/dependencies/GPT2-export.py](https://github.com/onnx/models/blob/main/text/machine_comprehension/gpt-2/dependencies/GPT2-export.py) + /// for the script changes. + GPT2LmHead, +} + +impl ModelUrl for MachineComprehension { + fn fetch_url(&self) -> &'static str { + match self { + MachineComprehension::BiDAF => "https://github.com/onnx/models/raw/main/text/machine_comprehension/bidirectional_attention_flow/model/bidaf-9.onnx", + MachineComprehension::BERTSquad => "https://github.com/onnx/models/raw/main/text/machine_comprehension/bert-squad/model/bertsquad-10.onnx", + MachineComprehension::RoBERTa(variant) => variant.fetch_url(), + MachineComprehension::GPT2(variant) => variant.fetch_url(), + } + } +} + +impl ModelUrl for RoBERTa { + fn fetch_url(&self) -> &'static str { + match self { + RoBERTa::RoBERTaBase => "https://github.com/onnx/models/raw/main/text/machine_comprehension/roberta/model/roberta-base-11.onnx", + RoBERTa::RoBERTaSequenceClassification => "https://github.com/onnx/models/raw/main/text/machine_comprehension/roberta/model/roberta-sequence-classification-9.onnx", + } + } +} + +impl ModelUrl for GPT2 { + fn fetch_url(&self) -> &'static str { + match self { + GPT2::GPT2 => "https://github.com/onnx/models/raw/main/text/machine_comprehension/gpt-2/model/gpt2-10.onnx", + GPT2::GPT2LmHead => "https://github.com/onnx/models/raw/main/text/machine_comprehension/gpt-2/model/gpt2-lm-head-10.onnx", + } + } +} + +impl From for AvailableOnnxModel { + fn from(model: MachineComprehension) -> Self { + AvailableOnnxModel::Language(Language::MachineComprehension(model)) + } +} + +impl From for AvailableOnnxModel { + fn from(model: RoBERTa) -> Self { + AvailableOnnxModel::Language(Language::MachineComprehension( + MachineComprehension::RoBERTa(model), + )) + } +} + +impl From for AvailableOnnxModel { + fn from(model: GPT2) -> Self { + AvailableOnnxModel::Language(Language::MachineComprehension(MachineComprehension::GPT2( + model, + ))) + } +} diff --git a/rust/onnxruntime/src/download/vision.rs b/rust/onnxruntime/src/download/vision.rs new file mode 100644 index 0000000000000..bc4d385b46fed --- /dev/null +++ b/rust/onnxruntime/src/download/vision.rs @@ -0,0 +1,45 @@ +//! Module defining computer vision models available to download. +//! +//! See [https://github.com/onnx/models#image_classification](https://github.com/onnx/models#image_classification) + +use super::ModelUrl; + +pub mod body_face_gesture_analysis; +pub mod domain_based_image_classification; +pub mod image_classification; +pub mod image_manipulation; +pub mod object_detection_image_segmentation; + +// Re-exports +pub use body_face_gesture_analysis::BodyFaceGestureAnalysis; +pub use domain_based_image_classification::DomainBasedImageClassification; +pub use image_classification::ImageClassification; +pub use image_manipulation::ImageManipulation; +pub use object_detection_image_segmentation::ObjectDetectionImageSegmentation; + +/// Computer vision model +#[derive(Debug, Clone)] +pub enum Vision { + /// Domain-based Image Classification + DomainBasedImageClassification(DomainBasedImageClassification), + /// Image classification model + ImageClassification(ImageClassification), + /// Object Detection & Image Segmentation + ObjectDetectionImageSegmentation(ObjectDetectionImageSegmentation), + /// Body, Face & Gesture Analysis + BodyFaceGestureAnalysis(BodyFaceGestureAnalysis), + /// Image Manipulation + ImageManipulation(ImageManipulation), +} + +impl ModelUrl for Vision { + fn fetch_url(&self) -> &'static str { + match self { + Vision::DomainBasedImageClassification(variant) => variant.fetch_url(), + Vision::ImageClassification(variant) => variant.fetch_url(), + Vision::ObjectDetectionImageSegmentation(variant) => variant.fetch_url(), + Vision::BodyFaceGestureAnalysis(variant) => variant.fetch_url(), + Vision::ImageManipulation(variant) => variant.fetch_url(), + } + } +} diff --git a/rust/onnxruntime/src/download/vision/body_face_gesture_analysis.rs b/rust/onnxruntime/src/download/vision/body_face_gesture_analysis.rs new file mode 100644 index 0000000000000..1916f85776076 --- /dev/null +++ b/rust/onnxruntime/src/download/vision/body_face_gesture_analysis.rs @@ -0,0 +1,43 @@ +//! Module defining body, face and gesture analysis models available to download. +//! +//! See [https://github.com/onnx/models#body_analysis](https://github.com/onnx/models#body_analysis) + +use crate::download::{vision::Vision, AvailableOnnxModel, ModelUrl}; + +/// Body, Face & Gesture Analysis +/// +/// > Face detection models identify and/or recognize human faces and emotions in given images. Body and Gesture +/// > Analysis models identify gender and age in given image. +/// +/// Source: [https://github.com/onnx/models#body_analysis](https://github.com/onnx/models#body_analysis) +#[derive(Debug, Clone)] +pub enum BodyFaceGestureAnalysis { + /// A CNN based model for face recognition which learns discriminative features of faces and produces + /// embeddings for input face images. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/body_analysis/arcface](https://github.com/onnx/models/tree/main/vision/body_analysis/arcface) + /// + /// Variant downloaded: ONNX Version 1.3 with Opset Version 8. + ArcFace, + /// Deep CNN for emotion recognition trained on images of faces. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/body_analysis/emotion_ferplus](https://github.com/onnx/models/tree/main/vision/body_analysis/emotion_ferplus) + /// + /// Variant downloaded: ONNX Version 1.3 with Opset Version 8. + EmotionFerPlus, +} + +impl ModelUrl for BodyFaceGestureAnalysis { + fn fetch_url(&self) -> &'static str { + match self { + BodyFaceGestureAnalysis::ArcFace => "https://github.com/onnx/models/raw/main/vision/body_analysis/arcface/model/arcfaceresnet100-8.onnx", + BodyFaceGestureAnalysis::EmotionFerPlus => "https://github.com/onnx/models/raw/main/vision/body_analysis/emotion_ferplus/model/emotion-ferplus-8.onnx", + } + } +} + +impl From for AvailableOnnxModel { + fn from(model: BodyFaceGestureAnalysis) -> Self { + AvailableOnnxModel::Vision(Vision::BodyFaceGestureAnalysis(model)) + } +} diff --git a/rust/onnxruntime/src/download/vision/domain_based_image_classification.rs b/rust/onnxruntime/src/download/vision/domain_based_image_classification.rs new file mode 100644 index 0000000000000..78387bf175795 --- /dev/null +++ b/rust/onnxruntime/src/download/vision/domain_based_image_classification.rs @@ -0,0 +1,30 @@ +//! Module defining domain-based image classification models available to download. +//! +//! See [https://github.com/onnx/models#domain-based-image-classification-](https://github.com/onnx/models#domain-based-image-classification-) + +use crate::download::{vision::Vision, AvailableOnnxModel, ModelUrl}; + +/// Image classification model +#[derive(Debug, Clone)] +pub enum DomainBasedImageClassification { + /// Handwritten digits prediction using CNN + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/mnist](https://github.com/onnx/models/tree/main/vision/classification/mnist) + /// + /// Variant downloaded: ONNX Version 1.3 with Opset Version 8. + Mnist, +} + +impl ModelUrl for DomainBasedImageClassification { + fn fetch_url(&self) -> &'static str { + match self { + DomainBasedImageClassification::Mnist => "https://github.com/onnx/models/raw/main/vision/classification/mnist/model/mnist-8.onnx", + } + } +} + +impl From for AvailableOnnxModel { + fn from(model: DomainBasedImageClassification) -> Self { + AvailableOnnxModel::Vision(Vision::DomainBasedImageClassification(model)) + } +} diff --git a/rust/onnxruntime/src/download/vision/image_classification.rs b/rust/onnxruntime/src/download/vision/image_classification.rs new file mode 100644 index 0000000000000..7806a75547a42 --- /dev/null +++ b/rust/onnxruntime/src/download/vision/image_classification.rs @@ -0,0 +1,350 @@ +//! Module defining image classification models available to download. +//! +//! See [https://github.com/onnx/models#image_classification](https://github.com/onnx/models#image_classification) + +// Acronyms are specific ONNX model names and contains upper cases +#![allow(clippy::upper_case_acronyms)] + +use crate::download::{vision::Vision, AvailableOnnxModel, ModelUrl}; + +/// Image classification model +/// +/// > This collection of models take images as input, then classifies the major objects in the images +/// > into 1000 object categories such as keyboard, mouse, pencil, and many animals. +/// +/// Source: [https://github.com/onnx/models#image-classification-](https://github.com/onnx/models#image-classification-) +#[derive(Debug, Clone)] +pub enum ImageClassification { + /// Image classification aimed for mobile targets. + /// + /// > MobileNet models perform image classification - they take images as input and classify the major + /// > object in the image into a set of pre-defined classes. They are trained on ImageNet dataset which + /// > contains images from 1000 classes. MobileNet models are also very efficient in terms of speed and + /// > size and hence are ideal for embedded and mobile applications. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/mobilenet](https://github.com/onnx/models/tree/main/vision/classification/mobilenet) + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + MobileNet, + /// Image classification, trained on ImageNet with 1000 classes. + /// + /// > ResNet models provide very high accuracies with affordable model sizes. They are ideal for cases when + /// > high accuracy of classification is required. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/resnet](https://github.com/onnx/models/tree/main/vision/classification/resnet) + ResNet(ResNet), + /// A small CNN with AlexNet level accuracy on ImageNet with 50x fewer parameters. + /// + /// > SqueezeNet is a small CNN which achieves AlexNet level accuracy on ImageNet with 50x fewer parameters. + /// > SqueezeNet requires less communication across servers during distributed training, less bandwidth to + /// > export a new model from the cloud to an autonomous car and more feasible to deploy on FPGAs and other + /// > hardware with limited memory. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/squeezenet](https://github.com/onnx/models/tree/main/vision/classification/squeezenet) + /// + /// Variant downloaded: SqueezeNet v1.1, ONNX Version 1.2.1 with Opset Version 7. + SqueezeNet, + /// Image classification, trained on ImageNet with 1000 classes. + /// + /// > VGG models provide very high accuracies but at the cost of increased model sizes. They are ideal for + /// > cases when high accuracy of classification is essential and there are limited constraints on model sizes. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/vgg](https://github.com/onnx/models/tree/main/vision/classification/vgg) + Vgg(Vgg), + /// Convolutional neural network for classification, which competed in the ImageNet Large Scale Visual Recognition Challenge in 2012. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/alexnet](https://github.com/onnx/models/tree/main/vision/classification/alexnet) + /// + /// Variant downloaded: ONNX Version 1.4 with Opset Version 9. + AlexNet, + /// Convolutional neural network for classification, which competed in the ImageNet Large Scale Visual Recognition Challenge in 2014. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/inception_and_googlenet/googlenet](https://github.com/onnx/models/tree/main/vision/classification/inception_and_googlenet/googlenet) + /// + /// Variant downloaded: ONNX Version 1.4 with Opset Version 9. + GoogleNet, + /// Variant of AlexNet, it's the name of a convolutional neural network for classification, which competed in the ImageNet Large Scale Visual Recognition Challenge in 2012. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/caffenet](https://github.com/onnx/models/tree/main/vision/classification/caffenet) + /// + /// Variant downloaded: ONNX Version 1.4 with Opset Version 9. + CaffeNet, + /// Convolutional neural network for detection. + /// + /// > This model was made by transplanting the R-CNN SVM classifiers into a fc-rcnn classification layer. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/rcnn_ilsvrc13](https://github.com/onnx/models/tree/main/vision/classification/rcnn_ilsvrc13) + /// + /// Variant downloaded: ONNX Version 1.4 with Opset Version 9. + RcnnIlsvrc13, + /// Convolutional neural network for classification. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/rcnn_ilsvrc13](https://github.com/onnx/models/tree/main/vision/classification/rcnn_ilsvrc13) + /// + /// Variant downloaded: ONNX Version 1.4 with Opset Version 9. + DenseNet121, + /// Google's Inception + Inception(InceptionVersion), + /// Computationally efficient CNN architecture designed specifically for mobile devices with very limited computing power. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/shufflenet](https://github.com/onnx/models/tree/main/vision/classification/shufflenet) + ShuffleNet(ShuffleNetVersion), + /// Deep convolutional networks for classification. + /// + /// > This model's 4th layer has 512 maps instead of 1024 maps mentioned in the paper. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/zfnet-512](https://github.com/onnx/models/tree/main/vision/classification/zfnet-512) + ZFNet512, + /// Image classification model that achieves state-of-the-art accuracy. + /// + /// > It is designed to run on mobile CPU, GPU, and EdgeTPU devices, allowing for applications on mobile and loT, where computational resources are limited. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/efficientnet-lite4](https://github.com/onnx/models/tree/main/vision/classification/efficientnet-lite4) + /// + /// Variant downloaded: ONNX Version 1.7.0 with Opset Version 11. + EfficientNetLite4, +} + +/// Google's Inception +#[derive(Debug, Clone)] +pub enum InceptionVersion { + /// Google's Inception v1 + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/inception_and_googlenet/inception_v1](https://github.com/onnx/models/tree/main/vision/classification/inception_and_googlenet/inception_v1) + /// + /// Variant downloaded: ONNX Version 1.4 with Opset Version 9. + V1, + /// Google's Inception v2 + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/inception_and_googlenet/inception_v2](https://github.com/onnx/models/tree/main/vision/classification/inception_and_googlenet/inception_v2) + /// + /// Variant downloaded: ONNX Version 1.4 with Opset Version 9. + V2, +} + +/// ResNet +/// +/// Source: [https://github.com/onnx/models/tree/main/vision/classification/resnet](https://github.com/onnx/models/tree/main/vision/classification/resnet) +#[derive(Debug, Clone)] +pub enum ResNet { + /// ResNet v1 + V1(ResNetV1), + /// ResNet v2 + V2(ResNetV2), +} +/// ResNet v1 +/// +/// Source: [https://github.com/onnx/models/tree/main/vision/classification/resnet](https://github.com/onnx/models/tree/main/vision/classification/resnet) +#[derive(Debug, Clone)] +pub enum ResNetV1 { + /// ResNet18 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet18, + /// ResNet34 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet34, + /// ResNet50 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet50, + /// ResNet101 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet101, + /// ResNet152 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet152, +} +/// ResNet v2 +/// +/// Source: [https://github.com/onnx/models/tree/main/vision/classification/resnet](https://github.com/onnx/models/tree/main/vision/classification/resnet) +#[derive(Debug, Clone)] +pub enum ResNetV2 { + /// ResNet18 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet18, + /// ResNet34 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet34, + /// ResNet50 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet50, + /// ResNet101 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet101, + /// ResNet152 + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + ResNet152, +} + +/// ResNet +/// +/// Source: [https://github.com/onnx/models/tree/main/vision/classification/resnet](https://github.com/onnx/models/tree/main/vision/classification/resnet) +#[derive(Debug, Clone)] +pub enum Vgg { + /// VGG with 16 convolutional layers + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + Vgg16, + /// VGG with 16 convolutional layers, with batch normalization applied after each convolutional layer. + /// + /// The batch normalization leads to better convergence and slightly better accuracies. + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + Vgg16Bn, + /// VGG with 19 convolutional layers + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + Vgg19, + /// VGG with 19 convolutional layers, with batch normalization applied after each convolutional layer. + /// + /// The batch normalization leads to better convergence and slightly better accuracies. + /// + /// Variant downloaded: ONNX Version 1.2.1 with Opset Version 7. + Vgg19Bn, +} + +/// Computationally efficient CNN architecture designed specifically for mobile devices with very limited computing power. +/// +/// Source: [https://github.com/onnx/models/tree/main/vision/classification/shufflenet](https://github.com/onnx/models/tree/main/vision/classification/shufflenet) +#[derive(Debug, Clone)] +pub enum ShuffleNetVersion { + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/shufflenet](https://github.com/onnx/models/tree/main/vision/classification/shufflenet) + /// + /// Variant downloaded: ONNX Version 1.4 with Opset Version 9. + V1, + /// ShuffleNetV2 is an improved architecture that is the state-of-the-art in terms of speed and accuracy tradeoff used for image classification. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/classification/shufflenet](https://github.com/onnx/models/tree/main/vision/classification/shufflenet) + /// + /// Variant downloaded: ONNX Version 1.6 with Opset Version 10. + V2, +} + +impl ModelUrl for ImageClassification { + fn fetch_url(&self) -> &'static str { + match self { + ImageClassification::MobileNet => "https://github.com/onnx/models/raw/main/vision/classification/mobilenet/model/mobilenetv2-7.onnx", + ImageClassification::SqueezeNet => "https://github.com/onnx/models/raw/main/vision/classification/squeezenet/model/squeezenet1.1-7.onnx", + ImageClassification::Inception(version) => version.fetch_url(), + ImageClassification::ResNet(version) => version.fetch_url(), + ImageClassification::Vgg(variant) => variant.fetch_url(), + ImageClassification::AlexNet => "https://github.com/onnx/models/raw/main/vision/classification/alexnet/model/bvlcalexnet-9.onnx", + ImageClassification::GoogleNet => "https://github.com/onnx/models/raw/main/vision/classification/inception_and_googlenet/googlenet/model/googlenet-9.onnx", + ImageClassification::CaffeNet => "https://github.com/onnx/models/raw/main/vision/classification/caffenet/model/caffenet-9.onnx", + ImageClassification::RcnnIlsvrc13 => "https://github.com/onnx/models/raw/main/vision/classification/rcnn_ilsvrc13/model/rcnn-ilsvrc13-9.onnx", + ImageClassification::DenseNet121 => "https://github.com/onnx/models/raw/main/vision/classification/densenet-121/model/densenet-9.onnx", + ImageClassification::ShuffleNet(version) => version.fetch_url(), + ImageClassification::ZFNet512 => "https://github.com/onnx/models/raw/main/vision/classification/zfnet-512/model/zfnet512-9.onnx", + ImageClassification::EfficientNetLite4 => "https://github.com/onnx/models/raw/main/vision/classification/efficientnet-lite4/model/efficientnet-lite4.onnx" + } + } +} + +impl ModelUrl for InceptionVersion { + fn fetch_url(&self) -> &'static str { + match self { + InceptionVersion::V1 => "https://github.com/onnx/models/raw/main/vision/classification/inception_and_googlenet/inception_v1/model/inception-v1-9.onnx", + InceptionVersion::V2 => "https://github.com/onnx/models/raw/main/vision/classification/inception_and_googlenet/inception_v2/model/inception-v2-9.onnx", + } + } +} + +impl ModelUrl for ResNet { + fn fetch_url(&self) -> &'static str { + match self { + ResNet::V1(variant) => variant.fetch_url(), + ResNet::V2(variant) => variant.fetch_url(), + } + } +} + +impl ModelUrl for ResNetV1 { + fn fetch_url(&self) -> &'static str { + match self { + ResNetV1::ResNet18 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet18-v1-7.onnx", + ResNetV1::ResNet34 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet34-v1-7.onnx", + ResNetV1::ResNet50 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet50-v1-7.onnx", + ResNetV1::ResNet101 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet101-v1-7.onnx", + ResNetV1::ResNet152 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet152-v1-7.onnx", + } + } +} + +impl ModelUrl for ResNetV2 { + fn fetch_url(&self) -> &'static str { + match self { + ResNetV2::ResNet18 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet18-v2-7.onnx", + ResNetV2::ResNet34 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet34-v2-7.onnx", + ResNetV2::ResNet50 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet50-v2-7.onnx", + ResNetV2::ResNet101 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet101-v2-7.onnx", + ResNetV2::ResNet152 => "https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet152-v2-7.onnx", + } + } +} + +impl ModelUrl for Vgg { + fn fetch_url(&self) -> &'static str { + match self { + Vgg::Vgg16 => "https://github.com/onnx/models/raw/main/vision/classification/vgg/model/vgg16-7.onnx", + Vgg::Vgg16Bn => "https://github.com/onnx/models/raw/main/vision/classification/vgg/model/vgg16-bn-7.onnx", + Vgg::Vgg19 => "https://github.com/onnx/models/raw/main/vision/classification/vgg/model/vgg19-7.onnx", + Vgg::Vgg19Bn => "https://github.com/onnx/models/raw/main/vision/classification/vgg/model/vgg19-bn-7.onnx", + } + } +} + +impl ModelUrl for ShuffleNetVersion { + fn fetch_url(&self) -> &'static str { + match self { + ShuffleNetVersion::V1 => "https://github.com/onnx/models/raw/main/vision/classification/shufflenet/model/shufflenet-9.onnx", + ShuffleNetVersion::V2 => "https://github.com/onnx/models/raw/main/vision/classification/shufflenet/model/shufflenet-v2-10.onnx", + } + } +} + +impl From for AvailableOnnxModel { + fn from(model: ImageClassification) -> Self { + AvailableOnnxModel::Vision(Vision::ImageClassification(model)) + } +} + +impl From for AvailableOnnxModel { + fn from(variant: ResNet) -> Self { + AvailableOnnxModel::Vision(Vision::ImageClassification(ImageClassification::ResNet( + variant, + ))) + } +} + +impl From for AvailableOnnxModel { + fn from(variant: Vgg) -> Self { + AvailableOnnxModel::Vision(Vision::ImageClassification(ImageClassification::Vgg( + variant, + ))) + } +} + +impl From for AvailableOnnxModel { + fn from(variant: InceptionVersion) -> Self { + AvailableOnnxModel::Vision(Vision::ImageClassification(ImageClassification::Inception( + variant, + ))) + } +} + +impl From for AvailableOnnxModel { + fn from(variant: ShuffleNetVersion) -> Self { + AvailableOnnxModel::Vision(Vision::ImageClassification( + ImageClassification::ShuffleNet(variant), + )) + } +} diff --git a/rust/onnxruntime/src/download/vision/image_manipulation.rs b/rust/onnxruntime/src/download/vision/image_manipulation.rs new file mode 100644 index 0000000000000..4a67e429133d1 --- /dev/null +++ b/rust/onnxruntime/src/download/vision/image_manipulation.rs @@ -0,0 +1,86 @@ +//! Module defining image manipulation models available to download. +//! +//! See [https://github.com/onnx/models#image_manipulation](https://github.com/onnx/models#image_manipulation) + +use crate::download::{vision::Vision, AvailableOnnxModel, ModelUrl}; + +/// Image Manipulation +/// +/// > Image manipulation models use neural networks to transform input images to modified output images. Some +/// > popular models in this category involve style transfer or enhancing images by increasing resolution. +/// +/// Source: [https://github.com/onnx/models#image_manipulation](https://github.com/onnx/models#image_manipulation) +#[derive(Debug, Clone)] +pub enum ImageManipulation { + /// Super Resolution + /// + /// > The Super Resolution machine learning model sharpens and upscales the input image to refine the + /// > details and improve quality. + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/super_resolution/sub_pixel_cnn_2016](https://github.com/onnx/models/tree/main/vision/super_resolution/sub_pixel_cnn_2016) + /// + /// Variant downloaded: ONNX Version 1.5 with Opset Version 10. + SuperResolution, + /// Fast Neural Style Transfer + /// + /// > This artistic style transfer model mixes the content of an image with the style of another image. + /// > Examples of the styles can be seen + /// > [in this PyTorch example](https://github.com/pytorch/examples/tree/main/fast_neural_style#models). + /// + /// Source: [https://github.com/onnx/models/tree/main/vision/style_transfer/fast_neural_style](https://github.com/onnx/models/tree/main/vision/style_transfer/fast_neural_style) + FastNeuralStyleTransfer(FastNeuralStyleTransferStyle), +} + +/// Fast Neural Style Transfer Style +/// +/// Source: [https://github.com/onnx/models/tree/main/vision/style_transfer/fast_neural_style](https://github.com/onnx/models/tree/main/vision/style_transfer/fast_neural_style) +/// +/// Variant downloaded: ONNX Version 1.4 with Opset Version 9. +#[derive(Debug, Clone)] +pub enum FastNeuralStyleTransferStyle { + /// Mosaic style + Mosaic, + /// Candy style + Candy, + /// RainPrincess style + RainPrincess, + /// Udnie style + Udnie, + /// Pointilism style + Pointilism, +} + +impl ModelUrl for ImageManipulation { + fn fetch_url(&self) -> &'static str { + match self { + ImageManipulation::SuperResolution => "https://github.com/onnx/models/raw/main/vision/super_resolution/sub_pixel_cnn_2016/model/super-resolution-10.onnx", + ImageManipulation::FastNeuralStyleTransfer(style) => style.fetch_url(), + } + } +} + +impl ModelUrl for FastNeuralStyleTransferStyle { + fn fetch_url(&self) -> &'static str { + match self { + FastNeuralStyleTransferStyle::Mosaic => "https://github.com/onnx/models/raw/main/vision/style_transfer/fast_neural_style/model/mosaic-9.onnx", + FastNeuralStyleTransferStyle::Candy => "https://github.com/onnx/models/raw/main/vision/style_transfer/fast_neural_style/model/candy-9.onnx", + FastNeuralStyleTransferStyle::RainPrincess => "https://github.com/onnx/models/raw/main/vision/style_transfer/fast_neural_style/model/rain-princess-9.onnx", + FastNeuralStyleTransferStyle::Udnie => "https://github.com/onnx/models/raw/main/vision/style_transfer/fast_neural_style/model/udnie-9.onnx", + FastNeuralStyleTransferStyle::Pointilism => "https://github.com/onnx/models/raw/main/vision/style_transfer/fast_neural_style/model/pointilism-9.onnx", + } + } +} + +impl From for AvailableOnnxModel { + fn from(model: ImageManipulation) -> Self { + AvailableOnnxModel::Vision(Vision::ImageManipulation(model)) + } +} + +impl From for AvailableOnnxModel { + fn from(style: FastNeuralStyleTransferStyle) -> Self { + AvailableOnnxModel::Vision(Vision::ImageManipulation( + ImageManipulation::FastNeuralStyleTransfer(style), + )) + } +} diff --git a/rust/onnxruntime/src/download/vision/object_detection_image_segmentation.rs b/rust/onnxruntime/src/download/vision/object_detection_image_segmentation.rs new file mode 100644 index 0000000000000..ff95154c20c21 --- /dev/null +++ b/rust/onnxruntime/src/download/vision/object_detection_image_segmentation.rs @@ -0,0 +1,107 @@ +//! Module defining object detection and image segmentation models available to download. +//! +//! See [https://github.com/onnx/models#object_detection](https://github.com/onnx/models#object_detection) + +// Acronyms are specific ONNX model names and contains upper cases +#![allow(clippy::upper_case_acronyms)] + +use crate::download::{vision::Vision, AvailableOnnxModel, ModelUrl}; + +/// Object Detection & Image Segmentation +/// +/// > Object detection models detect the presence of multiple objects in an image and segment out areas of the +/// > image where the objects are detected. Semantic segmentation models partition an input image by labeling each pixel +/// > into a set of pre-defined categories. +/// +/// Source: [https://github.com/onnx/models#object_detection](https://github.com/onnx/models#object_detection) +#[derive(Debug, Clone)] +pub enum ObjectDetectionImageSegmentation { + /// A real-time CNN for object detection that detects 20 different classes. A smaller version of the + /// more complex full YOLOv2 network. + /// + /// Variant downloaded: ONNX Version 1.3 with Opset Version 8. + TinyYoloV2, + /// Single Stage Detector: real-time CNN for object detection that detects 80 different classes. + /// + /// Variant downloaded: ONNX Version 1.5 with Opset Version 10. + Ssd, + /// A variant of MobileNet that uses the Single Shot Detector (SSD) model framework. The model detects 80 + /// different object classes and locates up to 10 objects in an image. + /// + /// Variant downloaded: ONNX Version 1.7.0 with Opset Version 10. + SSDMobileNetV1, + /// Increases efficiency from R-CNN by connecting a RPN with a CNN to create a single, unified network for + /// object detection that detects 80 different classes. + /// + /// Variant downloaded: ONNX Version 1.5 with Opset Version 10. + FasterRcnn, + /// A real-time neural network for object instance segmentation that detects 80 different classes. Extends + /// Faster R-CNN as each of the 300 elected ROIs go through 3 parallel branches of the network: label + /// prediction, bounding box prediction and mask prediction. + /// + /// Variant downloaded: ONNX Version 1.5 with Opset Version 10. + MaskRcnn, + /// A real-time dense detector network for object detection that addresses class imbalance through Focal Loss. + /// RetinaNet is able to match the speed of previous one-stage detectors and defines the state-of-the-art in + /// two-stage detectors (surpassing R-CNN). + /// + /// Variant downloaded: ONNX Version 1.6.0 with Opset Version 9. + RetinaNet, + /// A CNN model for real-time object detection system that can detect over 9000 object categories. It uses a + /// single network evaluation, enabling it to be more than 1000x faster than R-CNN and 100x faster than + /// Faster R-CNN. + /// + /// Variant downloaded: ONNX Version 1.3 with Opset Version 8. + YoloV2, + /// A CNN model for real-time object detection system that can detect over 9000 object categories. It uses + /// a single network evaluation, enabling it to be more than 1000x faster than R-CNN and 100x faster than + /// Faster R-CNN. This model is trained with COCO dataset and contains 80 classes. + /// + /// Variant downloaded: ONNX Version 1.5 with Opset Version 9. + YoloV2Coco, + /// A deep CNN model for real-time object detection that detects 80 different classes. A little bigger than + /// YOLOv2 but still very fast. As accurate as SSD but 3 times faster. + /// + /// Variant downloaded: ONNX Version 1.5 with Opset Version 10. + YoloV3, + /// A smaller version of YOLOv3 model. + /// + /// Variant downloaded: ONNX Version 1.6 with Opset Version 11. + TinyYoloV3, + /// Optimizes the speed and accuracy of object detection. Two times faster than EfficientDet. It improves + /// YOLOv3's AP and FPS by 10% and 12%, respectively, with mAP50 of 52.32 on the COCO 2017 dataset and + /// FPS of 41.7 on Tesla 100. + /// + /// Variant downloaded: ONNX Version 1.6 with Opset Version 11. + YoloV4, + /// Deep CNN based pixel-wise semantic segmentation model with >80% mIOU (mean Intersection Over Union). + /// Trained on cityscapes dataset, which can be effectively implemented in self driving vehicle systems. + /// + /// Variant downloaded: ONNX Version 1.2.2 with Opset Version 7. + Duc, +} + +impl ModelUrl for ObjectDetectionImageSegmentation { + fn fetch_url(&self) -> &'static str { + match self { + ObjectDetectionImageSegmentation::TinyYoloV2 => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/tiny-yolov2/model/tinyyolov2-8.onnx", + ObjectDetectionImageSegmentation::Ssd => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/ssd/model/ssd-10.onnx", + ObjectDetectionImageSegmentation::SSDMobileNetV1 => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/ssd-mobilenetv1/model/ssd_mobilenet_v1_10.onnx", + ObjectDetectionImageSegmentation::FasterRcnn => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/faster-rcnn/model/FasterRCNN-10.onnx", + ObjectDetectionImageSegmentation::MaskRcnn => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/mask-rcnn/model/MaskRCNN-10.onnx", + ObjectDetectionImageSegmentation::RetinaNet => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/retinanet/model/retinanet-9.onnx", + ObjectDetectionImageSegmentation::YoloV2 => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/yolov2/model/yolov2-voc-8.onnx", + ObjectDetectionImageSegmentation::YoloV2Coco => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/yolov2-coco/model/yolov2-coco-9.onnx", + ObjectDetectionImageSegmentation::YoloV3 => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/yolov3/model/yolov3-10.onnx", + ObjectDetectionImageSegmentation::TinyYoloV3 => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/tiny-yolov3/model/tiny-yolov3-11.onnx", + ObjectDetectionImageSegmentation::YoloV4 => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/yolov4/model/yolov4.onnx", + ObjectDetectionImageSegmentation::Duc => "https://github.com/onnx/models/raw/main/vision/object_detection_segmentation/duc/model/ResNet101-DUC-7.onnx", + } + } +} + +impl From for AvailableOnnxModel { + fn from(model: ObjectDetectionImageSegmentation) -> Self { + AvailableOnnxModel::Vision(Vision::ObjectDetectionImageSegmentation(model)) + } +} diff --git a/rust/onnxruntime/src/environment.rs b/rust/onnxruntime/src/environment.rs new file mode 100644 index 0000000000000..04c34ab38c7b9 --- /dev/null +++ b/rust/onnxruntime/src/environment.rs @@ -0,0 +1,373 @@ +//! Module containing environment types + +use crate::{ + error::{status_to_result, OrtError, Result}, + onnxruntime::custom_logger, + session::SessionBuilder, + LoggingLevel, +}; +use once_cell::sync::OnceCell; +use onnxruntime_sys as sys; +use onnxruntime_sys::library_filename; +use std::{ + ffi::CString, + ptr::{null, null_mut}, + sync::{Arc, Mutex, MutexGuard}, +}; +use sys::{onnxruntime, ORT_API_VERSION}; +use tracing::{debug, warn}; + +pub(crate) static ENV: OnceCell>> = OnceCell::new(); + +pub(crate) static LIB: OnceCell = OnceCell::new(); + +#[derive(Debug)] +pub(crate) struct _EnvironmentSingleton { + name: CString, + pub(crate) env_ptr: *mut sys::OrtEnv, + + pub api: *const sys::OrtApi, +} + +impl _EnvironmentSingleton { + pub(crate) unsafe fn api(&self) -> sys::OrtApi { + *self.api + } +} + +unsafe impl Send for _EnvironmentSingleton {} + +unsafe impl Sync for _EnvironmentSingleton {} + +/// An [`Environment`](session/struct.Environment.html) is the main entry point of the ONNX Runtime. +/// +/// Only one ONNXRuntime environment can be created per process. The `onnxruntime` crate +/// uses a singleton (through `lazy_static!()`) to enforce this. +/// +/// Once an environment is created, a [`Session`](../session/struct.Session.html) +/// can be obtained from it. +/// +/// **NOTE**: While the [`Environment`](environment/struct.Environment.html) constructor takes a `name` parameter +/// to name the environment, only the first name will be considered if many environments +/// are created. +/// +/// # Example +/// +/// ```no_run +/// # use std::error::Error; +/// # use std::env::var; +/// # use onnxruntime::{environment::Environment, LoggingLevel}; +/// # fn main() -> Result<(), Box> { +/// # let path = var("RUST_ONNXRUNTIME_LIBRARY_PATH").ok(); +/// +/// let builder = Environment::builder() +/// .with_name("test") +/// .with_log_level(LoggingLevel::Warning); +/// +/// let builder = if let Some(path) = path { +/// builder.with_library_path(path) +/// } else { +/// builder +/// }; +/// let environment = builder.build()?; +/// # Ok(()) +/// # } +/// ``` +pub struct Environment { + pub(crate) env: _Environment, +} + +#[derive(Debug, Clone)] +pub(crate) struct _Environment { + env: Arc>, +} + +impl _Environment { + pub(crate) fn env(&self) -> MutexGuard<_EnvironmentSingleton> { + self.env.lock().expect("The lock is poisoned") + } +} + +impl std::fmt::Debug for Environment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.env.fmt(f) + } +} + +impl Environment { + /// Create a new environment builder using default values + /// (name: `default`, log level: [`LoggingLevel::Warning`](../enum.LoggingLevel.html#variant.Warning)) + #[must_use] + pub fn builder() -> EnvBuilder { + EnvBuilder { + name: "default".into(), + log_level: LoggingLevel::Warning, + path: None, + } + } + + /// Return the name of the current environment + #[must_use] + pub fn name(&self) -> String { + self.env().name.to_str().unwrap().to_string() + } + + pub(crate) fn env(&self) -> MutexGuard<_EnvironmentSingleton> { + self.env.env() + } + + #[tracing::instrument] + fn new(name: &str, log_level: LoggingLevel, path: Option) -> Result { + let lib = if let Some(path) = path { + LIB.get_or_try_init(|| unsafe { onnxruntime::new(path) })? + } else { + LIB.get_or_try_init(|| unsafe { onnxruntime::new(library_filename("onnxruntime")) })? + }; + let env = ENV.get_or_try_init(|| { + debug!("Environment not yet initialized, creating a new one."); + + let api = unsafe { (*lib.OrtGetApiBase()).GetApi.unwrap()(ORT_API_VERSION) }; + + let mut env_ptr: *mut sys::OrtEnv = std::ptr::null_mut(); + + let logging_function: sys::OrtLoggingFunction = Some(custom_logger); + // FIXME: What should go here? + let logger_param: *mut std::ffi::c_void = std::ptr::null_mut(); + + let cname = CString::new(name).unwrap(); + unsafe { + let create_env_with_custom_logger = (*api).CreateEnvWithCustomLogger.unwrap(); + let status = create_env_with_custom_logger( + logging_function, + logger_param, + log_level.into(), + cname.as_ptr(), + &mut env_ptr, + ); + + status_to_result(status).map_err(OrtError::Environment)?; + } + debug!( + env_ptr = format!("{:?}", env_ptr).as_str(), + "Environment created." + ); + + Ok::<_, OrtError>(Arc::new(Mutex::new(_EnvironmentSingleton { + name: cname, + env_ptr, + api, + }))) + })?; + + let mut guard = env.lock().expect("Lock is poisoned"); + + if guard.env_ptr.is_null() || guard.api.is_null() { + debug!("Environment not yet initialized, creating a new one."); + + let api = unsafe { (*lib.OrtGetApiBase()).GetApi.unwrap()(ORT_API_VERSION) }; + + let mut env_ptr: *mut sys::OrtEnv = std::ptr::null_mut(); + + let logging_function: sys::OrtLoggingFunction = Some(custom_logger); + // FIXME: What should go here? + let logger_param: *mut std::ffi::c_void = std::ptr::null_mut(); + + let cname = CString::new(name).unwrap(); + unsafe { + let create_env_with_custom_logger = (*api).CreateEnvWithCustomLogger.unwrap(); + let status = create_env_with_custom_logger( + logging_function, + logger_param, + log_level.into(), + cname.as_ptr(), + &mut env_ptr, + ); + + status_to_result(status).map_err(OrtError::Environment)?; + } + debug!( + env_ptr = format!("{:?}", env_ptr).as_str(), + "Environment created." + ); + + guard.env_ptr = env_ptr; + guard.api = api; + guard.name = cname; + } + + Ok(Environment { + env: _Environment { env: env.clone() }, + }) + } + + /// Create a new [`SessionBuilder`](../session/struct.SessionBuilder.html) + /// used to create a new ONNXRuntime session. + pub fn new_session_builder(&self) -> Result { + SessionBuilder::new(self) + } +} + +impl Drop for Environment { + fn drop(&mut self) { + if Arc::strong_count(ENV.get().unwrap()) == 2 { + let env = &mut *ENV.get().unwrap().lock().expect("Lock is poisoned"); + + unsafe { + let release_env = env.api().ReleaseEnv.unwrap(); + release_env(env.env_ptr); + + env.api = null(); + + env.env_ptr = null_mut(); + env.name = CString::default(); + }; + } + } +} + +/// Struct used to build an environment [`Environment`](environment/struct.Environment.html) +/// +/// This is the crate's main entry point. An environment _must_ be created +/// as the first step. An [`Environment`](environment/struct.Environment.html) can only be built +/// using `EnvBuilder` to configure it. +/// +/// **NOTE**: If the same configuration method (for example [`with_name()`](struct.EnvBuilder.html#method.with_name)) +/// is called multiple times, the last value will have precedence. +pub struct EnvBuilder { + name: String, + log_level: LoggingLevel, + path: Option, +} + +impl EnvBuilder { + /// Configure the environment with a given name + /// + /// **NOTE**: Since ONNXRuntime can only define one environment per process, + /// creating multiple environments using multiple `EnvBuilder` will + /// end up re-using the same environment internally; a new one will _not_ + /// be created. New parameters will be ignored. + pub fn with_name(mut self, name: S) -> EnvBuilder + where + S: Into, + { + self.name = name.into(); + self + } + + /// Add a library path to the Onnxruntime shared library. + /// + /// **Note**: The library path can be an absolute path or relative (to the executable) path. + /// If no library path is specified, it is expected that the OS can find the Onnxruntime shared + /// library in the normal manner to that OS. + pub fn with_library_path>(mut self, path: P) -> EnvBuilder { + self.path = Some(path.into()); + self + } + + /// Configure the environment with a given log level + /// + /// **NOTE**: Since ONNXRuntime can only define one environment per process, + /// creating multiple environments using multiple `EnvBuilder` will + /// end up re-using the same environment internally; a new one will _not_ + /// be created. New parameters will be ignored. + #[must_use] + pub fn with_log_level(mut self, log_level: LoggingLevel) -> EnvBuilder { + self.log_level = log_level; + self + } + + /// Commit the configuration to a new [`Environment`](environment/struct.Environment.html) + pub fn build(self) -> Result { + Environment::new(&self.name, self.log_level, self.path) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::env::var; + + use super::*; + use test_log::test; + + pub(crate) static ONNX_RUNTIME_LIBRARY_PATH: &str = "RUST_ONNXRUNTIME_LIBRARY_PATH"; + + #[test] + fn sequential_environment_creation() { + let first_name: String = "sequential_environment_creation".into(); + + let path = var(ONNX_RUNTIME_LIBRARY_PATH).ok(); + + let builder = Environment::builder() + .with_name(first_name.clone()) + .with_log_level(LoggingLevel::Warning); + + let builder = if let Some(path) = path.clone() { + builder.with_library_path(path) + } else { + builder + }; + + let env = builder.build().unwrap(); + + let mut prev_env_ptr = env.env().env_ptr; + + for i in 0..10 { + let name = format!("sequential_environment_creation: {}", i); + let builder = Environment::builder() + .with_name(name.clone()) + .with_log_level(LoggingLevel::Warning); + + let builder = if let Some(ref path) = path { + builder.with_library_path(path) + } else { + builder + }; + + let env = builder.build().unwrap(); + let next_env_ptr = env.env().env_ptr; + assert_eq!(next_env_ptr, prev_env_ptr); + prev_env_ptr = next_env_ptr; + } + } + + #[test] + fn concurrent_environment_creations() { + let initial_name = "concurrent_environment_creation"; + + let path = var(ONNX_RUNTIME_LIBRARY_PATH).ok(); + + let main_env = Environment::new(initial_name, LoggingLevel::Warning, path.clone()).unwrap(); + let main_env_ptr = main_env.env().env_ptr as usize; + + let children: Vec<_> = (0..10) + .map(|t| { + let path = path.clone(); + + std::thread::spawn(move || { + let name = format!("concurrent_environment_creation: {}", t); + let builder = Environment::builder() + .with_name(name.clone()) + .with_log_level(LoggingLevel::Warning); + + let builder = if let Some(path) = path { + builder.with_library_path(path) + } else { + builder + }; + + let env = builder.build().unwrap(); + + assert_eq!(env.env().env_ptr as usize, main_env_ptr); + }) + }) + .collect(); + + assert_eq!(main_env.env().env_ptr as usize, main_env_ptr); + + let res: Vec> = children + .into_iter() + .map(std::thread::JoinHandle::join) + .collect(); + assert!(res.into_iter().all(|r| std::result::Result::is_ok(&r))); + } +} diff --git a/rust/onnxruntime/src/error.rs b/rust/onnxruntime/src/error.rs new file mode 100644 index 0000000000000..fc44e2b33930e --- /dev/null +++ b/rust/onnxruntime/src/error.rs @@ -0,0 +1,249 @@ +//! Module containing error definitions. + +use std::{io, path::PathBuf}; + +use thiserror::Error; + +use onnxruntime_sys as sys; + +use crate::{char_p_to_string, environment::ENV}; + +/// Type alias for the `Result` +pub type Result = std::result::Result; + +/// Error type centralizing all possible errors +#[non_exhaustive] +#[derive(Error, Debug)] +pub enum OrtError { + /// For errors with libloading + #[error("Failed to load or call onnxruntime library {0}")] + Library(#[from] libloading::Error), + /// The C API can message to the caller using a C `char *` which needs to be converted + /// to Rust's `String`. This operation can fail. + #[error("Failed to construct String")] + StringConversion(OrtApiError), + // FIXME: Move these to another enum (they are C API calls errors) + /// An error occurred when creating an ONNXRuntime environment + #[error("Failed to create environment: {0}")] + Environment(OrtApiError), + /// Error occurred when creating an ONNXRuntime session options + #[error("Failed to create session options: {0}")] + SessionOptions(OrtApiError), + /// Error occurred when creating an ONNXRuntime session + #[error("Failed to create session: {0}")] + Session(OrtApiError), + /// Error occurred when creating an ONNXRuntime allocator + #[error("Failed to get allocator: {0}")] + Allocator(OrtApiError), + /// Error occurred when counting ONNXRuntime input or output count + #[error("Failed to get input or output count: {0}")] + InOutCount(OrtApiError), + /// Error occurred when getting ONNXRuntime input name + #[error("Failed to get input name: {0}")] + InputName(OrtApiError), + /// Error occurred when getting ONNXRuntime type information + #[error("Failed to get type info: {0}")] + GetTypeInfo(OrtApiError), + /// Error occurred when casting ONNXRuntime type information to tensor information + #[error("Failed to cast type info to tensor info: {0}")] + CastTypeInfoToTensorInfo(OrtApiError), + /// Error occurred when getting tensor elements type + #[error("Failed to get tensor element type: {0}")] + TensorElementType(OrtApiError), + /// Error occurred when getting ONNXRuntime dimensions count + #[error("Failed to get dimensions count: {0}")] + GetDimensionsCount(OrtApiError), + /// Error occurred when getting ONNXRuntime dimensions + #[error("Failed to get dimensions: {0}")] + GetDimensions(OrtApiError), + /// Error occurred when creating CPU memory information + #[error("Failed to get dimensions: {0}")] + CreateCpuMemoryInfo(OrtApiError), + /// Error occurred when creating ONNXRuntime tensor + #[error("Failed to create tensor: {0}")] + CreateTensor(OrtApiError), + /// Error occurred when creating ONNXRuntime tensor with specific data + #[error("Failed to create tensor with data: {0}")] + CreateTensorWithData(OrtApiError), + /// Error occurred when filling a tensor with string data + #[error("Failed to fill string tensor: {0}")] + FillStringTensor(OrtApiError), + /// Error occurred when checking if ONNXRuntime tensor was properly initialized + #[error("Failed to check if tensor: {0}")] + IsTensor(OrtApiError), + /// Error occurred when getting tensor type and shape + #[error("Failed to get tensor type and shape: {0}")] + GetTensorTypeAndShape(OrtApiError), + /// Error occurred when ONNXRuntime inference operation was called + #[error("Failed to run: {0}")] + Run(OrtApiError), + /// Error occurred when extracting data from an ONNXRuntime tensor into an C array to be used as an `ndarray::ArrayView` + #[error("Failed to get tensor data: {0}")] + GetTensorMutableData(OrtApiError), + + /// Error occurred when downloading a pre-trained ONNX model from the [ONNX Model Zoo](https://github.com/onnx/models) + #[error("Failed to download ONNX model: {0}")] + DownloadError(#[from] OrtDownloadError), + + /// Dimensions of input data and ONNX model loaded from file do not match + #[error("Dimensions do not match: {0:?}")] + NonMatchingDimensions(NonMatchingDimensionsError), + /// File does not exists + #[error("File {filename:?} does not exists")] + FileDoesNotExists { + /// Path which does not exists + filename: PathBuf, + }, + /// Path is an invalid UTF-8 + #[error("Path {path:?} cannot be converted to UTF-8")] + NonUtf8Path { + /// Path with invalid UTF-8 + path: PathBuf, + }, + /// Attempt to build a Rust `CString` from a null pointer + #[error("Failed to build CString when original contains null: {0}")] + CStringNulError(#[from] std::ffi::NulError), + #[error("{0} pointer should be null")] + /// Ort Pointer should have been null + PointerShouldBeNull(String), + /// Ort pointer should not have been null + #[error("{0} pointer should not be null")] + PointerShouldNotBeNull(String), + /// ONNXRuntime Model has invalid dimensions + #[error("Invalid dimensions")] + InvalidDimensions, + /// The runtime type was undefined + #[error("Undefined Tensor Element Type")] + UndefinedTensorElementType, + /// Error occurred when checking if ONNXRuntime tensor was properly initialized + #[error("Failed to check if tensor")] + IsTensorCheck, +} + +/// Error used when dimensions of input (from model and from inference call) +/// do not match (as they should). +#[non_exhaustive] +#[derive(Error, Debug)] +pub enum NonMatchingDimensionsError { + /// Number of inputs from model does not match number of inputs from inference call + #[error("Non-matching number of inputs: {inference_input_count:?} for input vs {model_input_count:?} for model (inputs: {inference_input:?}, model: {model_input:?})")] + InputsCount { + /// Number of input dimensions used by inference call + inference_input_count: usize, + /// Number of input dimensions defined in model + model_input_count: usize, + /// Input dimensions used by inference call + inference_input: Vec>, + /// Input dimensions defined in model + model_input: Vec>>, + }, + /// Inputs length from model does not match the expected input from inference call + #[error("Different input lengths: Expected Input: {model_input:?} vs Received Input: {inference_input:?}")] + InputsLength { + /// Input dimensions used by inference call + inference_input: Vec>, + /// Input dimensions defined in model + model_input: Vec>>, + }, +} + +/// Error details when ONNXRuntime C API fail +#[non_exhaustive] +#[derive(Error, Debug)] +pub enum OrtApiError { + /// Details as reported by the ONNXRuntime C API in case of error + #[error("Error calling ONNX Runtime C function: {0}")] + Msg(String), + /// Details as reported by the ONNXRuntime C API in case of error cannot be converted to UTF-8 + #[error("Error calling ONNX Runtime C function and failed to convert error message to UTF-8")] + IntoStringError(std::ffi::IntoStringError), +} + +/// Error from downloading pre-trained model from the [ONNX Model Zoo](https://github.com/onnx/models). +#[non_exhaustive] +#[derive(Error, Debug)] +pub enum OrtDownloadError { + /// Generic input/output error + #[error("Error downloading data to file: {0}")] + IoError(#[from] io::Error), + #[cfg(feature = "model-fetching")] + /// Download error by ureq + #[error("Error downloading data to file: {0}")] + UreqError(#[from] Box), + /// Error getting content-length from an HTTP GET request + #[error("Error getting content-length")] + ContentLengthError, + /// Mismatch between amount of downloaded and expected bytes + #[error("Error copying data to file: expected {expected} length, received {io}")] + CopyError { + /// Expected amount of bytes to download + expected: u64, + /// Number of bytes read from network and written to file + io: u64, + }, +} + +/// Wrapper type around a ONNXRuntime C API's `OrtStatus` pointer +/// +/// This wrapper exists to facilitate conversion from C raw pointers to Rust error types +pub struct OrtStatusWrapper(*const sys::OrtStatus); + +impl From<*const sys::OrtStatus> for OrtStatusWrapper { + fn from(status: *const sys::OrtStatus) -> Self { + OrtStatusWrapper(status) + } +} + +pub(crate) fn assert_null_pointer(ptr: *const T, name: &str) -> Result<()> { + ptr.is_null() + .then_some(()) + .ok_or_else(|| OrtError::PointerShouldBeNull(name.to_owned())) +} + +pub(crate) fn assert_not_null_pointer(ptr: *const T, name: &str) -> Result<()> { + (!ptr.is_null()) + .then_some(()) + .ok_or_else(|| OrtError::PointerShouldBeNull(name.to_owned())) +} + +impl From for std::result::Result<(), OrtApiError> { + fn from(status: OrtStatusWrapper) -> Self { + if status.0.is_null() { + Ok(()) + } else { + let raw: *const i8 = unsafe { + ENV.get() + .unwrap() + .lock() + .unwrap() + .api() + .GetErrorMessage + .unwrap()(status.0) + }; + match char_p_to_string(raw) { + Ok(msg) => Err(OrtApiError::Msg(msg)), + Err(err) => match err { + OrtError::StringConversion(OrtApiError::IntoStringError(e)) => { + Err(OrtApiError::IntoStringError(e)) + } + _ => unreachable!(), + }, + } + } + } +} + +pub(crate) fn status_to_result( + status: *const sys::OrtStatus, +) -> std::result::Result<(), OrtApiError> { + let status_wrapper: OrtStatusWrapper = status.into(); + status_wrapper.into() +} + +/// A wrapper around a function on `OrtApi` that maps the status code into [`OrtApiError`] +pub(crate) unsafe fn call_ort(mut f: F) -> std::result::Result<(), OrtApiError> +where + F: FnMut(sys::OrtApi) -> *const sys::OrtStatus, +{ + status_to_result(f(ENV.get().unwrap().lock().unwrap().api())) +} diff --git a/rust/onnxruntime/src/lib.rs b/rust/onnxruntime/src/lib.rs new file mode 100644 index 0000000000000..ce4721ef4240f --- /dev/null +++ b/rust/onnxruntime/src/lib.rs @@ -0,0 +1,560 @@ +#![warn(missing_docs)] + +//! ONNX Runtime +//! +//! This crate is a (safe) wrapper around Microsoft's [ONNX Runtime](https://github.com/microsoft/onnxruntime/) +//! through its C API. +//! +//! From its [GitHub page](https://github.com/microsoft/onnxruntime/): +//! +//! > ONNX Runtime is a cross-platform, high performance ML inferencing and training accelerator. +//! +//! The (highly) unsafe [C API](https://github.com/microsoft/onnxruntime/blob/main/include/onnxruntime/core/session/onnxruntime_c_api.h) +//! is wrapped using bindgen as [`onnxruntime-sys`](https://crates.io/crates/onnxruntime-sys). +//! +//! The unsafe bindings are wrapped in this crate to expose a safe API. +//! +//! For now, efforts are concentrated on the inference API. Training is _not_ supported. +//! +//! # Example +//! +//! The C++ example that uses the C API +//! ([`C_Api_Sample.cpp`](https://github.com/microsoft/onnxruntime/blob/v1.3.1/csharp/test/Microsoft.ML.OnnxRuntime.EndToEndTests.Capi/C_Api_Sample.cpp)) +//! was ported to +//! [`onnxruntime`](https://github.com/nbigaouette/onnxruntime-rs/blob/main/onnxruntime/examples/sample.rs). +//! +//! First, an environment must be created using and [`EnvBuilder`](environment/struct.EnvBuilder.html): +//! +//! ```no_run +//! # use std::error::Error; +//! # use std::env::var; +//! # use onnxruntime::{environment::Environment, LoggingLevel}; +//! # fn main() -> Result<(), Box> { +//! # let path = var("RUST_ONNXRUNTIME_LIBRARY_PATH").ok(); +//! +//! let builder = Environment::builder() +//! .with_name("test") +//! .with_log_level(LoggingLevel::Warning); +//! +//! let builder = if let Some(path) = path { +//! builder.with_library_path(path) +//! } else { +//! builder +//! }; +//! let environment = builder.build()?; +//! Ok(()) +//! } +//! ``` +//! +//! Then a [`Session`](session/struct.Session.html) is created from the environment, some options and an ONNX model file: +//! +//! ```no_run +//! # use std::error::Error; +//! # use std::env::var; +//! # use onnxruntime::{environment::Environment, LoggingLevel, GraphOptimizationLevel}; +//! # fn main() -> Result<(), Box> { +//! # let path = var("RUST_ONNXRUNTIME_LIBRARY_PATH").ok(); +//! # +//! # let builder = Environment::builder() +//! # .with_name("test") +//! # .with_log_level(LoggingLevel::Warning); +//! # +//! # let builder = if let Some(path) = path { +//! # builder.with_library_path(path) +//! # } else { +//! # builder +//! # }; +//! # let environment = builder.build()?; +//! let mut session = environment +//! .new_session_builder()? +//! .with_graph_optimization_level(GraphOptimizationLevel::Basic)? +//! .with_intra_op_num_threads(1)? +//! .with_model_from_file("squeezenet.onnx")?; +//! # Ok(()) +//! # } +//! ``` +#![cfg_attr( + feature = "model-fetching", + doc = r##" +Instead of loading a model from file using [`with_model_from_file()`](session/struct.SessionBuilder.html#method.with_model_from_file), +a model can be fetched directly from the [ONNX Model Zoo](https://github.com/onnx/models) using +[`with_model_downloaded()`](session/struct.SessionBuilder.html#method.with_model_downloaded) method +(requires the `model-fetching` feature). + +```no_run +# use std::error::Error; +# use std::env::var; +# use onnxruntime::{environment::Environment, download::vision::ImageClassification, LoggingLevel, GraphOptimizationLevel}; +# fn main() -> Result<(), Box> { +# let path = var("RUST_ONNXRUNTIME_LIBRARY_PATH").ok(); +# +# let builder = Environment::builder() +# .with_name("test") +# .with_log_level(LoggingLevel::Warning); +# +# let builder = if let Some(path) = path { +# builder.with_library_path(path) +# } else { +# builder +# }; +# let environment = builder.build()?; + +let mut session = environment + .new_session_builder()? + .with_graph_optimization_level(GraphOptimizationLevel::Basic)? + .with_intra_op_num_threads(1)? + .with_model_downloaded(ImageClassification::SqueezeNet)?; +# Ok(()) +# } +``` + +See [`AvailableOnnxModel`](download/enum.AvailableOnnxModel.html) for the different models available +to download. +"## +)] +//! +//! Inference will be run on data passed as an [`ndarray::Array`](https://docs.rs/ndarray/latest/ndarray/type.Array.html). +//! +//! ```no_run +//! # use std::error::Error; +//! # use std::env::var; +//! # use onnxruntime::{environment::Environment, LoggingLevel, GraphOptimizationLevel, tensor::construct::ConstructTensor}; +//! # fn main() -> Result<(), Box> { +//! # let path = var("RUST_ONNXRUNTIME_LIBRARY_PATH").ok(); +//! # +//! # let builder = Environment::builder() +//! # .with_name("test") +//! # .with_log_level(LoggingLevel::Warning); +//! # +//! # let builder = if let Some(path) = path { +//! # builder.with_library_path(path) +//! # } else { +//! # builder +//! # }; +//! # let environment = builder.build()?; +//! # let mut session = environment +//! # .new_session_builder()? +//! # .with_graph_optimization_level(GraphOptimizationLevel::Basic)? +//! # .with_intra_op_num_threads(1)? +//! # .with_model_from_file("squeezenet.onnx")?; +//! let array = ndarray::Array::linspace(0.0_f32, 1.0, 100); +//! // Multiple inputs and outputs are possible +//! let input_tensor = vec![array.into()]; +//! let outputs = session.run(input_tensor)?; +//! # Ok(()) +//! # } +//! ``` +//! +//! The outputs are of type [`OrtOwnedTensor`](tensor/ort_owned_tensor/struct.OrtOwnedTensor.html)s inside a vector, +//! with the same length as the inputs. +//! +//! See the [`sample.rs`](https://github.com/nbigaouette/onnxruntime-rs/blob/main/onnxruntime/examples/sample.rs) +//! example for more details. + +use onnxruntime_sys as sys; + +// Make functions `extern "stdcall"` for Windows 32bit. +// This behaviors like `extern "system"`. +#[cfg(all(target_os = "windows", target_arch = "x86"))] +macro_rules! extern_system_fn { + ($(#[$meta:meta])* fn $($tt:tt)*) => ($(#[$meta])* extern "stdcall" fn $($tt)*); + ($(#[$meta:meta])* $vis:vis fn $($tt:tt)*) => ($(#[$meta])* $vis extern "stdcall" fn $($tt)*); + ($(#[$meta:meta])* unsafe fn $($tt:tt)*) => ($(#[$meta])* unsafe extern "stdcall" fn $($tt)*); + ($(#[$meta:meta])* $vis:vis unsafe fn $($tt:tt)*) => ($(#[$meta])* $vis unsafe extern "stdcall" fn $($tt)*); +} + +// Make functions `extern "C"` for normal targets. +// This behaviors like `extern "system"`. +#[cfg(not(all(target_os = "windows", target_arch = "x86")))] +macro_rules! extern_system_fn { + ($(#[$meta:meta])* fn $($tt:tt)*) => ($(#[$meta])* extern "C" fn $($tt)*); + ($(#[$meta:meta])* $vis:vis fn $($tt:tt)*) => ($(#[$meta])* $vis extern "C" fn $($tt)*); + ($(#[$meta:meta])* unsafe fn $($tt:tt)*) => ($(#[$meta])* unsafe extern "C" fn $($tt)*); + ($(#[$meta:meta])* $vis:vis unsafe fn $($tt:tt)*) => ($(#[$meta])* $vis unsafe extern "C" fn $($tt)*); +} + +pub mod download; +pub mod environment; +pub mod error; +mod memory; +pub mod session; +pub mod tensor; + +// Re-export +pub use error::{OrtApiError, OrtError, Result}; +use sys::OnnxEnumInt; + +// Re-export ndarray as it's part of the public API anyway +pub use ndarray; + +fn char_p_to_string(raw: *const i8) -> Result { + let c_string = unsafe { std::ffi::CStr::from_ptr(raw as *mut i8).to_owned() }; + + match c_string.into_string() { + Ok(string) => Ok(string), + Err(e) => Err(OrtApiError::IntoStringError(e)), + } + .map_err(OrtError::StringConversion) +} + +mod onnxruntime { + //! Module containing a custom logger, used to catch the runtime's own logging and send it + //! to Rust's tracing logging instead. + + use std::ffi::CStr; + use tracing::{debug, error, info, span, trace, warn, Level}; + + use onnxruntime_sys as sys; + + /// Runtime's logging sends the code location where the log happened, will be parsed to this struct. + #[derive(Debug)] + struct CodeLocation<'a> { + file: &'a str, + line_number: &'a str, + function: &'a str, + } + + impl<'a> From<&'a str> for CodeLocation<'a> { + fn from(code_location: &'a str) -> Self { + let mut splitter = code_location.split(' '); + let file_and_line_number = splitter.next().unwrap_or(""); + let function = splitter.next().unwrap_or(""); + let mut file_and_line_number_splitter = file_and_line_number.split(':'); + let file = file_and_line_number_splitter + .next() + .unwrap_or(""); + let line_number = file_and_line_number_splitter + .next() + .unwrap_or(""); + + CodeLocation { + file, + line_number, + function, + } + } + } + + extern_system_fn! { + /// Callback from C that will handle the logging, forwarding the runtime's logs to the tracing crate. + pub(crate) fn custom_logger( + _params: *mut std::ffi::c_void, + severity: sys::OrtLoggingLevel, + category: *const i8, + logid: *const i8, + code_location: *const i8, + message: *const i8, + ) { + let log_level = match severity { + sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_VERBOSE => Level::TRACE, + sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_INFO => Level::DEBUG, + sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_WARNING => Level::INFO, + sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_ERROR => Level::WARN, + sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_FATAL => Level::ERROR, + }; + + assert_ne!(category, std::ptr::null()); + let category = unsafe { CStr::from_ptr(category) }; + assert_ne!(code_location, std::ptr::null()); + let code_location = unsafe { CStr::from_ptr(code_location) } + .to_str() + .unwrap_or("unknown"); + assert_ne!(message, std::ptr::null()); + let message = unsafe { CStr::from_ptr(message) }; + + assert_ne!(logid, std::ptr::null()); + let logid = unsafe { CStr::from_ptr(logid) }; + + // Parse the code location + let code_location: CodeLocation = code_location.into(); + + let span = span!( + Level::TRACE, + "onnxruntime", + category = category.to_str().unwrap_or(""), + file = code_location.file, + line_number = code_location.line_number, + function = code_location.function, + logid = logid.to_str().unwrap_or(""), + ); + let _enter = span.enter(); + + match log_level { + Level::TRACE => trace!("{:?}", message), + Level::DEBUG => debug!("{:?}", message), + Level::INFO => info!("{:?}", message), + Level::WARN => warn!("{:?}", message), + Level::ERROR => error!("{:?}", message), + } + } + } +} + +/// Logging level of the ONNX Runtime C API +#[derive(Debug, Clone, Copy)] +#[cfg_attr(not(windows), repr(u32))] +#[cfg_attr(windows, repr(i32))] +pub enum LoggingLevel { + /// Verbose log level + Verbose = sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_VERBOSE as OnnxEnumInt, + /// Info log level + Info = sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_INFO as OnnxEnumInt, + /// Warning log level + Warning = sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_WARNING as OnnxEnumInt, + /// Error log level + Error = sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_ERROR as OnnxEnumInt, + /// Fatal log level + Fatal = sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_FATAL as OnnxEnumInt, +} + +impl From for sys::OrtLoggingLevel { + fn from(val: LoggingLevel) -> Self { + match val { + LoggingLevel::Verbose => sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_VERBOSE, + LoggingLevel::Info => sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_INFO, + LoggingLevel::Warning => sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_WARNING, + LoggingLevel::Error => sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_ERROR, + LoggingLevel::Fatal => sys::OrtLoggingLevel::ORT_LOGGING_LEVEL_FATAL, + } + } +} + +/// Optimization level performed by ONNX Runtime of the loaded graph +/// +/// See the [official documentation](https://github.com/microsoft/onnxruntime/blob/main/docs/ONNX_Runtime_Graph_Optimizations.md) +/// for more information on the different optimization levels. +#[derive(Debug)] +#[cfg_attr(not(windows), repr(u32))] +#[cfg_attr(windows, repr(i32))] +pub enum GraphOptimizationLevel { + /// Disable optimization + DisableAll = sys::GraphOptimizationLevel::ORT_DISABLE_ALL as OnnxEnumInt, + /// Basic optimization + Basic = sys::GraphOptimizationLevel::ORT_ENABLE_BASIC as OnnxEnumInt, + /// Extended optimization + Extended = sys::GraphOptimizationLevel::ORT_ENABLE_EXTENDED as OnnxEnumInt, + /// Add optimization + All = sys::GraphOptimizationLevel::ORT_ENABLE_ALL as OnnxEnumInt, +} + +impl From for sys::GraphOptimizationLevel { + fn from(val: GraphOptimizationLevel) -> Self { + use GraphOptimizationLevel::{All, Basic, DisableAll, Extended}; + match val { + DisableAll => sys::GraphOptimizationLevel::ORT_DISABLE_ALL, + Basic => sys::GraphOptimizationLevel::ORT_ENABLE_BASIC, + Extended => sys::GraphOptimizationLevel::ORT_ENABLE_EXTENDED, + All => sys::GraphOptimizationLevel::ORT_ENABLE_ALL, + } + } +} + +// FIXME: Use https://docs.rs/bindgen/0.54.1/bindgen/struct.Builder.html#method.rustified_enum +// FIXME: Add tests to cover the commented out types +/// Enum mapping ONNX Runtime's supported tensor types +#[derive(Debug)] +#[cfg_attr(not(windows), repr(u32))] +#[cfg_attr(windows, repr(i32))] +pub enum TensorElementDataType { + /// 32-bit floating point, equivalent to Rust's `f32` + Float = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT as OnnxEnumInt, + /// Unsigned 8-bit int, equivalent to Rust's `u8` + Uint8 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT8 as OnnxEnumInt, + /// Signed 8-bit int, equivalent to Rust's `i8` + Int8 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT8 as OnnxEnumInt, + /// Unsigned 16-bit int, equivalent to Rust's `u16` + Uint16 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT16 as OnnxEnumInt, + /// Signed 16-bit int, equivalent to Rust's `i16` + Int16 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT16 as OnnxEnumInt, + /// Signed 32-bit int, equivalent to Rust's `i32` + Int32 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT32 as OnnxEnumInt, + /// Signed 64-bit int, equivalent to Rust's `i64` + Int64 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64 as OnnxEnumInt, + /// String, equivalent to Rust's `String` + String = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_STRING as OnnxEnumInt, + // /// Boolean, equivalent to Rust's `bool` + // Bool = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_BOOL as OnnxEnumInt, + // /// 16-bit floating point, equivalent to Rust's `f16` + // Float16 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16 as OnnxEnumInt, + /// 64-bit floating point, equivalent to Rust's `f64` + Double = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_DOUBLE as OnnxEnumInt, + /// Unsigned 32-bit int, equivalent to Rust's `u32` + Uint32 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT32 as OnnxEnumInt, + /// Unsigned 64-bit int, equivalent to Rust's `u64` + Uint64 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT64 as OnnxEnumInt, + // /// Complex 64-bit floating point, equivalent to Rust's `???` + // Complex64 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX64 as OnnxEnumInt, + // /// Complex 128-bit floating point, equivalent to Rust's `???` + // Complex128 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX128 as OnnxEnumInt, + // /// Brain 16-bit floating point + // Bfloat16 = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_BFLOAT16 as OnnxEnumInt, +} + +impl From for sys::ONNXTensorElementDataType { + fn from(val: TensorElementDataType) -> Self { + use TensorElementDataType::{ + Double, Float, Int16, Int32, Int64, Int8, String, Uint16, Uint32, Uint64, Uint8, + }; + match val { + Float => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT, + Uint8 => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT8, + Int8 => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT8, + Uint16 => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT16, + Int16 => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT16, + Int32 => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT32, + Int64 => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64, + String => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_STRING, + // Bool => { + // sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_BOOL + // } + // Float16 => { + // sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16 + // } + Double => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_DOUBLE, + Uint32 => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT32, + Uint64 => sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT64, + // Complex64 => { + // sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX64 + // } + // Complex128 => { + // sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX128 + // } + // Bfloat16 => { + // sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_BFLOAT16 + // } + } + } +} + +/// Trait used to map Rust types (for example `f32`) to ONNX types (for example `Float`) +pub trait TypeToTensorElementDataType { + /// Return the ONNX type for a Rust type + fn tensor_element_data_type() -> TensorElementDataType; + + /// If the type is `String`, returns `Some` with utf8 contents, else `None`. + fn try_utf8_bytes(&self) -> Option<&[u8]>; +} + +macro_rules! impl_type_trait { + ($type_:ty, $variant:ident) => { + impl TypeToTensorElementDataType for $type_ { + fn tensor_element_data_type() -> TensorElementDataType { + // unsafe { std::mem::transmute(TensorElementDataType::$variant) } + TensorElementDataType::$variant + } + + fn try_utf8_bytes(&self) -> Option<&[u8]> { + None + } + } + }; +} + +impl_type_trait!(f32, Float); +impl_type_trait!(u8, Uint8); +impl_type_trait!(i8, Int8); +impl_type_trait!(u16, Uint16); +impl_type_trait!(i16, Int16); +impl_type_trait!(i32, Int32); +impl_type_trait!(i64, Int64); +// impl_type_trait!(bool, Bool); +// impl_type_trait!(f16, Float16); +impl_type_trait!(f64, Double); +impl_type_trait!(u32, Uint32); +impl_type_trait!(u64, Uint64); +// impl_type_trait!(, Complex64); +// impl_type_trait!(, Complex128); +// impl_type_trait!(, Bfloat16); + +/// Adapter for common Rust string types to Onnx strings. +/// +/// It should be easy to use both `String` and `&str` as [`TensorElementDataType::String`] data, but +/// we can't define an automatic implementation for anything that implements `AsRef` as it +/// would conflict with the implementations of [`TypeToTensorElementDataType`] for primitive numeric +/// types (which might implement `AsRef` at some point in the future). +pub trait Utf8Data { + /// Returns the utf8 contents. + fn utf8_bytes(&self) -> &[u8]; +} + +impl Utf8Data for String { + fn utf8_bytes(&self) -> &[u8] { + self.as_bytes() + } +} + +impl<'a> Utf8Data for &'a str { + fn utf8_bytes(&self) -> &[u8] { + self.as_bytes() + } +} + +impl TypeToTensorElementDataType for T { + fn tensor_element_data_type() -> TensorElementDataType { + TensorElementDataType::String + } + + fn try_utf8_bytes(&self) -> Option<&[u8]> { + Some(self.utf8_bytes()) + } +} + +/// Allocator type +#[derive(Debug, Clone)] +#[repr(i32)] +pub enum AllocatorType { + // Invalid = sys::OrtAllocatorType::Invalid as i32, + /// Device allocator + Device = sys::OrtAllocatorType::OrtDeviceAllocator as i32, + /// Arena allocator + Arena = sys::OrtAllocatorType::OrtArenaAllocator as i32, +} + +impl From for sys::OrtAllocatorType { + fn from(val: AllocatorType) -> Self { + use AllocatorType::{Arena, Device}; + match val { + // Invalid => sys::OrtAllocatorType::Invalid, + Device => sys::OrtAllocatorType::OrtDeviceAllocator, + Arena => sys::OrtAllocatorType::OrtArenaAllocator, + } + } +} + +/// Memory type +/// +/// Only support ONNX's default type for now. +#[derive(Debug, Clone)] +#[repr(i32)] +pub enum MemType { + // FIXME: C API's `OrtMemType_OrtMemTypeCPU` defines it equal to `OrtMemType_OrtMemTypeCPUOutput`. How to handle this?? + // CPUInput = sys::OrtMemType::OrtMemTypeCPUInput as i32, + // CPUOutput = sys::OrtMemType::OrtMemTypeCPUOutput as i32, + // CPU = sys::OrtMemType::OrtMemTypeCPU as i32, + /// Default memory type + Default = sys::OrtMemType::OrtMemTypeDefault as i32, +} + +impl From for sys::OrtMemType { + fn from(val: MemType) -> Self { + use MemType::Default; + match val { + // CPUInput => sys::OrtMemType::OrtMemTypeCPUInput, + // CPUOutput => sys::OrtMemType::OrtMemTypeCPUOutput, + // CPU => sys::OrtMemType::OrtMemTypeCPU, + Default => sys::OrtMemType::OrtMemTypeDefault, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_char_p_to_string() { + let s = std::ffi::CString::new("foo").unwrap(); + let ptr = s.as_c_str().as_ptr(); + assert_eq!("foo", char_p_to_string(ptr).unwrap()); + } +} diff --git a/rust/onnxruntime/src/memory.rs b/rust/onnxruntime/src/memory.rs new file mode 100644 index 0000000000000..1688d433fe276 --- /dev/null +++ b/rust/onnxruntime/src/memory.rs @@ -0,0 +1,81 @@ +use tracing::debug; + +use onnxruntime_sys as sys; + +use crate::{ + environment::{Environment, _Environment}, + error::{assert_not_null_pointer, status_to_result, OrtError, Result}, + AllocatorType, MemType, +}; + +use tracing::error; + +#[derive(Debug)] +pub struct MemoryInfo { + pub ptr: *mut sys::OrtMemoryInfo, + env: _Environment, +} + +impl MemoryInfo { + #[tracing::instrument] + pub fn new(allocator: AllocatorType, memory_type: MemType, env: &Environment) -> Result { + debug!("Creating new memory info."); + let mut memory_info_ptr: *mut sys::OrtMemoryInfo = std::ptr::null_mut(); + let status = unsafe { + env.env().api().CreateCpuMemoryInfo.unwrap()( + allocator.into(), + memory_type.into(), + &mut memory_info_ptr, + ) + }; + status_to_result(status).map_err(OrtError::CreateCpuMemoryInfo)?; + assert_not_null_pointer(memory_info_ptr, "MemoryInfo")?; + + Ok(Self { + ptr: memory_info_ptr, + env: env.env.clone(), + }) + } +} + +impl Drop for MemoryInfo { + #[tracing::instrument] + fn drop(&mut self) { + if self.ptr.is_null() { + error!("MemoryInfo pointer is null, not dropping."); + } else { + debug!("Dropping the memory information."); + unsafe { self.env.env().api().ReleaseMemoryInfo.unwrap()(self.ptr) }; + } + + self.ptr = std::ptr::null_mut(); + } +} + +#[cfg(test)] +mod tests { + use std::env::var; + + use super::*; + use crate::{environment::tests::ONNX_RUNTIME_LIBRARY_PATH, LoggingLevel}; + use test_log::test; + + #[test] + fn memory_info_constructor_destructor() { + let path = var(ONNX_RUNTIME_LIBRARY_PATH).ok(); + + let builder = Environment::builder() + .with_name("test") + .with_log_level(LoggingLevel::Warning); + + let builder = if let Some(path) = path { + builder.with_library_path(path) + } else { + builder + }; + let env = builder.build().unwrap(); + + let memory_info = MemoryInfo::new(AllocatorType::Arena, MemType::Default, &env).unwrap(); + std::mem::drop(memory_info); + } +} diff --git a/rust/onnxruntime/src/session.rs b/rust/onnxruntime/src/session.rs new file mode 100644 index 0000000000000..326426e35982c --- /dev/null +++ b/rust/onnxruntime/src/session.rs @@ -0,0 +1,806 @@ +//! Module containing session types + +use std::{convert::TryFrom, ffi::CString, fmt::Debug, path::Path}; + +#[cfg(not(target_family = "windows"))] +use std::os::unix::ffi::OsStrExt; +#[cfg(target_family = "windows")] +use std::os::windows::ffi::OsStrExt; + +#[cfg(feature = "model-fetching")] +use std::env; + +use crate::{ + char_p_to_string, + environment::{Environment, _Environment}, + error::{ + assert_not_null_pointer, assert_null_pointer, status_to_result, NonMatchingDimensionsError, + OrtApiError, OrtError, Result, + }, + memory::MemoryInfo, + tensor::{ + construct::ConstructTensor, + ort_output_tensor::{OrtOutput, OrtOwnedTensorExtractor}, + OrtOutputTensor, + }, + AllocatorType, GraphOptimizationLevel, MemType, TensorElementDataType, +}; +use onnxruntime_sys as sys; + +use tracing::{debug, error}; + +#[cfg(feature = "model-fetching")] +use crate::{download::AvailableOnnxModel, error::OrtDownloadError}; + +/// Type used to create a session using the _builder pattern_ +/// +/// A `SessionBuilder` is created by calling the +/// [`Environment::new_session_builder()`](../env/struct.Environment.html#method.new_session_builder) +/// method on the environment. +/// +/// Once created, use the different methods to configure the session. +/// +/// Once configured, use the [`SessionBuilder::with_model_from_file()`](../session/struct.SessionBuilder.html#method.with_model_from_file) +/// method to "commit" the builder configuration into a [`Session`](../session/struct.Session.html). +/// +/// # Example +/// +/// ```no_run +/// # use std::error::Error; +/// # use std::env::var; +/// # use onnxruntime::{environment::Environment, LoggingLevel, GraphOptimizationLevel}; +/// # fn main() -> Result<(), Box> { +/// # let path = var("RUST_ONNXRUNTIME_LIBRARY_PATH").ok(); +/// +/// let builder = Environment::builder() +/// .with_name("test") +/// .with_log_level(LoggingLevel::Warning); +/// +/// let builder = if let Some(path) = path { +/// builder.with_library_path(path) +/// } else { +/// builder +/// }; +/// let environment = builder.build()?; +/// +/// let mut session = environment +/// .new_session_builder()? +/// .with_graph_optimization_level(GraphOptimizationLevel::Basic)? +/// .with_intra_op_num_threads(1)? +/// .with_model_from_file("squeezenet.onnx")?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct SessionBuilder<'a> { + env: &'a Environment, + session_options_ptr: *mut sys::OrtSessionOptions, + + allocator: AllocatorType, + memory_type: MemType, +} + +impl<'a> Drop for SessionBuilder<'a> { + #[tracing::instrument] + fn drop(&mut self) { + if self.session_options_ptr.is_null() { + error!("Session options pointer is null, not dropping"); + } else { + debug!("Dropping the session options."); + unsafe { + self.env.env().api().ReleaseSessionOptions.unwrap()(self.session_options_ptr) + }; + } + } +} + +impl<'a> SessionBuilder<'a> { + pub(crate) fn new(env: &'a Environment) -> Result> { + let mut session_options_ptr: *mut sys::OrtSessionOptions = std::ptr::null_mut(); + let status = + unsafe { env.env().api().CreateSessionOptions.unwrap()(&mut session_options_ptr) }; + + status_to_result(status).map_err(OrtError::SessionOptions)?; + assert_null_pointer(status, "SessionStatus")?; + assert_not_null_pointer(session_options_ptr, "SessionOptions")?; + + Ok(SessionBuilder { + env, + session_options_ptr, + allocator: AllocatorType::Arena, + memory_type: MemType::Default, + }) + } + + /// Configure the session to use a number of threads + pub fn with_intra_op_num_threads(self, num_threads: i16) -> Result> { + // FIXME: Pre-built binaries use OpenMP, set env variable instead + + // We use a u16 in the builder to cover the 16-bits positive values of a i32. + let num_threads = i32::from(num_threads); + let status = unsafe { + self.env.env().api().SetIntraOpNumThreads.unwrap()( + self.session_options_ptr, + num_threads, + ) + }; + status_to_result(status).map_err(OrtError::SessionOptions)?; + assert_null_pointer(status, "SessionStatus")?; + Ok(self) + } + + /// Set the session's optimization level + pub fn with_graph_optimization_level( + self, + opt_level: GraphOptimizationLevel, + ) -> Result> { + // Sets graph optimization level + unsafe { + self.env + .env() + .api() + .SetSessionGraphOptimizationLevel + .unwrap()(self.session_options_ptr, opt_level.into()) + }; + Ok(self) + } + + /// Set the session's allocator + /// + /// Defaults to [`AllocatorType::Arena`](../enum.AllocatorType.html#variant.Arena) + pub fn with_allocator(mut self, allocator: AllocatorType) -> Result> { + self.allocator = allocator; + Ok(self) + } + + /// Set the session's memory type + /// + /// Defaults to [`MemType::Default`](../enum.MemType.html#variant.Default) + pub fn with_memory_type(mut self, memory_type: MemType) -> Result> { + self.memory_type = memory_type; + Ok(self) + } + + /// Download an ONNX pre-trained model from the [ONNX Model Zoo](https://github.com/onnx/models) and commit the session + #[cfg(feature = "model-fetching")] + pub fn with_model_downloaded(self, model: M) -> Result + where + M: Into, + { + self.with_model_downloaded_monomorphized(model.into()) + } + + #[cfg(feature = "model-fetching")] + fn with_model_downloaded_monomorphized(self, model: AvailableOnnxModel) -> Result { + let download_dir = env::current_dir().map_err(OrtDownloadError::IoError)?; + let downloaded_path = model.download_to(download_dir)?; + self.with_model_from_file(downloaded_path) + } + + // TODO: Add all functions changing the options. + // See all OrtApi methods taking a `options: *mut OrtSessionOptions`. + + /// Load an ONNX graph from a file and commit the session + pub fn with_model_from_file

(self, model_filepath_ref: P) -> Result + where + P: AsRef + 'a, + { + let model_filepath = model_filepath_ref.as_ref(); + let mut session_ptr: *mut sys::OrtSession = std::ptr::null_mut(); + + if !model_filepath.exists() { + return Err(OrtError::FileDoesNotExists { + filename: model_filepath.to_path_buf(), + }); + } + + // Build an OsString than a vector of bytes to pass to C + let model_path = std::ffi::OsString::from(model_filepath); + #[cfg(target_family = "windows")] + let model_path: Vec = model_path + .encode_wide() + .chain(std::iter::once(0)) // Make sure we have a null terminated string + .collect(); + #[cfg(not(target_family = "windows"))] + let model_path: Vec = model_path + .as_bytes() + .iter() + .chain(std::iter::once(&b'\0')) // Make sure we have a null terminated string + .map(|b| *b as std::os::raw::c_char) + .collect(); + + unsafe { + let api = self.env.env().api(); + + let status = api.CreateSession.unwrap()( + self.env.env().env_ptr, + model_path.as_ptr(), + self.session_options_ptr, + &mut session_ptr, + ); + + status_to_result(status).map_err(OrtError::Session)?; + assert_null_pointer(status, "SessionStatus")?; + assert_not_null_pointer(session_ptr, "Session")?; + }; + let mut allocator_ptr: *mut sys::OrtAllocator = std::ptr::null_mut(); + let status = unsafe { + self.env.env().api().GetAllocatorWithDefaultOptions.unwrap()(&mut allocator_ptr) + }; + status_to_result(status).map_err(OrtError::Allocator)?; + assert_null_pointer(status, "SessionStatus")?; + assert_not_null_pointer(allocator_ptr, "Allocator")?; + + let memory_info = MemoryInfo::new(AllocatorType::Arena, MemType::Default, &self.env)?; + unsafe { + // Extract input and output properties + let num_input_nodes = + dangerous::extract_inputs_count(session_ptr, self.env.env.clone())?; + let num_output_nodes = + dangerous::extract_outputs_count(session_ptr, self.env.env.clone())?; + let inputs = (0..num_input_nodes) + .map(|i| { + dangerous::extract_input(session_ptr, allocator_ptr, i, self.env.env.clone()) + }) + .collect::>>()?; + let outputs = (0..num_output_nodes) + .map(|i| { + dangerous::extract_output(session_ptr, allocator_ptr, i, self.env.env.clone()) + }) + .collect::>>()?; + + Ok(Session { + env: self.env.env.clone(), + session_ptr, + allocator_ptr, + memory_info, + inputs, + outputs, + }) + } + } + + /// Load an ONNX graph from memory and commit the session + pub fn with_model_from_memory(self, model_bytes: B) -> Result + where + B: AsRef<[u8]>, + { + self.with_model_from_memory_monomorphized(model_bytes.as_ref()) + } + + fn with_model_from_memory_monomorphized(self, model_bytes: &[u8]) -> Result { + let mut session_ptr: *mut sys::OrtSession = std::ptr::null_mut(); + unsafe { + let api = self.env.env().api(); + + let model_data = model_bytes.as_ptr().cast::(); + let model_data_length = model_bytes.len(); + let status = api.CreateSessionFromArray.unwrap()( + self.env.env().env_ptr, + model_data, + model_data_length, + self.session_options_ptr, + &mut session_ptr, + ); + + status_to_result(status).map_err(OrtError::Session)?; + assert_null_pointer(status, "SessionStatus")?; + assert_not_null_pointer(session_ptr, "Session")?; + }; + let mut allocator_ptr: *mut sys::OrtAllocator = std::ptr::null_mut(); + let status = unsafe { + self.env.env().api().GetAllocatorWithDefaultOptions.unwrap()(&mut allocator_ptr) + }; + status_to_result(status).map_err(OrtError::Allocator)?; + assert_null_pointer(status, "SessionStatus")?; + assert_not_null_pointer(allocator_ptr, "Allocator")?; + + let memory_info = MemoryInfo::new(AllocatorType::Arena, MemType::Default, &self.env)?; + unsafe { + // Extract input and output properties + let num_input_nodes = + dangerous::extract_inputs_count(session_ptr, self.env.env.clone())?; + let num_output_nodes = + dangerous::extract_outputs_count(session_ptr, self.env.env.clone())?; + let inputs = (0..num_input_nodes) + .map(|i| { + dangerous::extract_input(session_ptr, allocator_ptr, i, self.env.env.clone()) + }) + .collect::>>()?; + let outputs = (0..num_output_nodes) + .map(|i| { + dangerous::extract_output(session_ptr, allocator_ptr, i, self.env.env.clone()) + }) + .collect::>>()?; + + Ok(Session { + env: self.env.env.clone(), + session_ptr, + allocator_ptr, + memory_info, + inputs, + outputs, + }) + } + } +} + +/// Type storing the session information, built from an [`Environment`](environment/struct.Environment.html) +#[derive(Debug)] +pub struct Session { + env: _Environment, + session_ptr: *mut sys::OrtSession, + allocator_ptr: *mut sys::OrtAllocator, + memory_info: MemoryInfo, + /// Information about the ONNX's inputs as stored in loaded file + pub inputs: Vec, + /// Information about the ONNX's outputs as stored in loaded file + pub outputs: Vec, +} + +/// Information about an ONNX's input as stored in loaded file +#[derive(Debug)] +pub struct Input { + /// Name of the input layer + pub name: String, + /// Type of the input layer's elements + pub input_type: TensorElementDataType, + /// Shape of the input layer + /// + /// C API uses a i64 for the dimensions. We use an unsigned of the same range of the positive values. + pub dimensions: Vec>, +} + +/// Information about an ONNX's output as stored in loaded file +#[derive(Debug)] +pub struct Output { + /// Name of the output layer + pub name: String, + /// Type of the output layer's elements + pub output_type: TensorElementDataType, + /// Shape of the output layer + /// + /// C API uses a i64 for the dimensions. We use an unsigned of the same range of the positive values. + pub dimensions: Vec>, +} + +impl Input { + /// Return an iterator over the shape elements of the input layer + /// + /// Note: The member [`Input::dimensions`](struct.Input.html#structfield.dimensions) + /// stores `u32` (since ONNX uses `i64` but which cannot be negative) so the + /// iterator converts to `usize`. + pub fn dimensions(&self) -> impl Iterator> + '_ { + self.dimensions.iter().map(|d| d.map(|d2| d2 as usize)) + } +} + +impl Output { + /// Return an iterator over the shape elements of the output layer + /// + /// Note: The member [`Output::dimensions`](struct.Output.html#structfield.dimensions) + /// stores `u32` (since ONNX uses `i64` but which cannot be negative) so the + /// iterator converts to `usize`. + pub fn dimensions(&self) -> impl Iterator> + '_ { + self.dimensions.iter().map(|d| d.map(|d2| d2 as usize)) + } +} + +impl Drop for Session { + #[tracing::instrument] + fn drop(&mut self) { + debug!("Dropping the session."); + if self.session_ptr.is_null() { + error!("Session pointer is null, not dropping."); + } else { + unsafe { self.env.env().api().ReleaseSession.unwrap()(self.session_ptr) }; + } + + self.session_ptr = std::ptr::null_mut(); + self.allocator_ptr = std::ptr::null_mut(); + } +} + +unsafe impl Send for Session {} + +unsafe impl Sync for Session {} + +impl Session { + /// Run the input data through the ONNX graph, performing inference. + /// + /// Note that ONNX models can have multiple inputs; a `Vec<_>` is thus + /// used for the input data here. + pub fn run<'input, 'output>( + &'output self, + mut input_arrays: impl AsMut<[Box]> + 'input, + ) -> Result>> { + let mut output_tensor_extractors_ptrs: Vec<*mut sys::OrtValue> = + vec![std::ptr::null_mut(); self.outputs.len()]; + + let output_names_cstring: Vec = self + .outputs + .iter() + .map(|output| output.name.clone()) + .map(|n| CString::new(n).unwrap()) + .collect(); + let output_names_ptr: Vec<*const i8> = output_names_cstring + .iter() + .map(|n| n.as_ptr().cast::()) + .collect(); + + let input_names_ptr: Vec<*const i8> = self + .inputs + .iter() + .map(|input| input.name.clone()) + .map(|n| CString::new(n).unwrap()) + .map(|n| n.into_raw() as *const i8) + .collect(); + + { + let memory_info = &self.memory_info; + + let allocator = self.allocator_ptr; + + let arr = input_arrays.as_mut(); + + let input_tensors = arr + .into_iter() + .map(|v| v.construct(memory_info, allocator)) + .collect::>>()?; + + let input_arrays_shapes: Vec> = + input_tensors.iter().map(|v| v.shape().to_vec()).collect(); + + self.validate_input_shapes(&input_arrays_shapes)?; + + // Build arguments to Run() + + let input_ort_values: Vec<*const sys::OrtValue> = input_tensors + .iter() + .map(|input_array_ort| input_array_ort.ptr() as *const sys::OrtValue) + .collect(); + + let run_options_ptr: *const sys::OrtRunOptions = std::ptr::null(); + + let status = unsafe { + self.env.env().api().Run.unwrap()( + self.session_ptr, + run_options_ptr, + input_names_ptr.as_ptr(), + input_ort_values.as_ptr(), + input_ort_values.len(), + output_names_ptr.as_ptr(), + output_names_ptr.len(), + output_tensor_extractors_ptrs.as_mut_ptr(), + ) + }; + status_to_result(status).map_err(OrtError::Run)?; + } + + let outputs: Result> = output_tensor_extractors_ptrs + .into_iter() + .map(|ptr| { + let mut tensor_info_ptr: *mut sys::OrtTensorTypeAndShapeInfo = std::ptr::null_mut(); + let status = unsafe { + self.env.env().api().GetTensorTypeAndShape.unwrap()( + ptr, + &mut tensor_info_ptr as _, + ) + }; + status_to_result(status).map_err(OrtError::GetTensorTypeAndShape)?; + let dims = unsafe { get_tensor_dimensions(tensor_info_ptr, self.env.clone()) }; + + unsafe { + self.env.env().api().ReleaseTensorTypeAndShapeInfo.unwrap()(tensor_info_ptr) + }; + let dims: Vec<_> = dims?.iter().map(|&n| n as usize).collect(); + + let mut output_tensor_extractor = + OrtOwnedTensorExtractor::new(dims, self.env.clone()); + output_tensor_extractor.tensor_ptr = ptr; + + output_tensor_extractor.extract() + }) + .collect(); + + // Reconvert to CString so drop impl is called and memory is freed + let cstrings: Result> = input_names_ptr + .into_iter() + .map(|p| { + assert_not_null_pointer(p, "i8 for CString")?; + unsafe { Ok(CString::from_raw(p as *mut i8)) } + }) + .collect(); + cstrings?; + + outputs? + .into_iter() + .map(|v| OrtOutput::try_from(v)) + .collect() + } + + fn validate_input_shapes(&self, input_array_shapes: &[Vec]) -> Result<()> { + // ****************************************************************** + // FIXME: Properly handle errors here + // Make sure all dimensions match (except dynamic ones) + + // Verify length of inputs + if input_array_shapes.len() != self.inputs.len() { + error!( + "Non-matching number of inputs: {} (inference) vs {} (model)", + input_array_shapes.len(), + self.inputs.len() + ); + return Err(OrtError::NonMatchingDimensions( + NonMatchingDimensionsError::InputsCount { + inference_input_count: 0, + model_input_count: 0, + inference_input: input_array_shapes.to_vec(), + model_input: self + .inputs + .iter() + .map(|input| input.dimensions.clone()) + .collect(), + }, + )); + } + + // Verify length of each individual inputs + let inputs_different_length = input_array_shapes + .iter() + .zip(self.inputs.iter()) + .any(|(l, r)| l.len() != r.dimensions.len()); + if inputs_different_length { + error!( + "Different input lengths: {:?} vs {:?}", + self.inputs, input_array_shapes + ); + return Err(OrtError::NonMatchingDimensions( + NonMatchingDimensionsError::InputsLength { + inference_input: input_array_shapes + .iter() + .map(|input_array| input_array.to_vec()) + .collect(), + model_input: self + .inputs + .iter() + .map(|input| input.dimensions.clone()) + .collect(), + }, + )); + } + + // Verify shape of each individual inputs + let inputs_different_shape = + input_array_shapes + .iter() + .zip(self.inputs.iter()) + .any(|(l, r)| { + let l_shape = l; + let r_shape = r.dimensions.as_slice(); + l_shape.iter().zip(r_shape.iter()).any(|(l2, r2)| match r2 { + Some(r3) => *r3 as usize != *l2, + None => false, // None means dynamic size; in that case shape always match + }) + }); + if inputs_different_shape { + error!( + "Different input lengths: {:?} vs {:?}", + self.inputs, input_array_shapes + ); + return Err(OrtError::NonMatchingDimensions( + NonMatchingDimensionsError::InputsLength { + inference_input: input_array_shapes + .iter() + .map(|input_array| input_array.to_vec()) + .collect(), + model_input: self + .inputs + .iter() + .map(|input| input.dimensions.clone()) + .collect(), + }, + )); + } + + Ok(()) + } +} + +unsafe fn get_tensor_dimensions( + tensor_info_ptr: *const sys::OrtTensorTypeAndShapeInfo, + env: _Environment, +) -> Result> { + let mut num_dims = 0; + let status = env.env().api().GetDimensionsCount.unwrap()(tensor_info_ptr, &mut num_dims); + status_to_result(status).map_err(OrtError::GetDimensionsCount)?; + (num_dims != 0) + .then_some(()) + .ok_or(OrtError::InvalidDimensions)?; + + let mut node_dims: Vec = vec![0; num_dims as usize]; + let status = env.env().api().GetDimensions.unwrap()( + tensor_info_ptr, + node_dims.as_mut_ptr(), // FIXME: UB? + num_dims, + ); + status_to_result(status).map_err(OrtError::GetDimensions)?; + Ok(node_dims) +} + +/// This module contains dangerous functions working on raw pointers. +/// Those functions are only to be used from inside the +/// `SessionBuilder::with_model_from_file()` method. +mod dangerous { + use super::{ + assert_not_null_pointer, assert_null_pointer, char_p_to_string, get_tensor_dimensions, + status_to_result, sys, Input, OrtApiError, OrtError, Output, Result, TensorElementDataType, + }; + + use crate::environment::_Environment; + + pub(super) unsafe fn extract_inputs_count( + session_ptr: *mut sys::OrtSession, + env: _Environment, + ) -> Result { + let f = env.env().api().SessionGetInputCount.unwrap(); + extract_io_count(f, session_ptr) + } + + pub(super) unsafe fn extract_outputs_count( + session_ptr: *mut sys::OrtSession, + env: _Environment, + ) -> Result { + let f = env.env().api().SessionGetOutputCount.unwrap(); + extract_io_count(f, session_ptr) + } + + fn extract_io_count( + f: extern_system_fn! { unsafe fn(*const sys::OrtSession, *mut usize) -> *mut sys::OrtStatus }, + session_ptr: *mut sys::OrtSession, + ) -> Result { + let mut num_nodes: usize = 0; + let status = unsafe { f(session_ptr, &mut num_nodes) }; + status_to_result(status).map_err(OrtError::InOutCount)?; + assert_null_pointer(status, "SessionStatus")?; + (num_nodes != 0).then_some(()).ok_or_else(|| { + OrtError::InOutCount(OrtApiError::Msg("No nodes in model".to_owned())) + })?; + Ok(num_nodes) + } + + unsafe fn extract_input_name( + session_ptr: *mut sys::OrtSession, + allocator_ptr: *mut sys::OrtAllocator, + i: usize, + env: _Environment, + ) -> Result { + let f = env.env().api().SessionGetInputName.unwrap(); + extract_io_name(f, session_ptr, allocator_ptr, i, env) + } + + unsafe fn extract_output_name( + session_ptr: *mut sys::OrtSession, + allocator_ptr: *mut sys::OrtAllocator, + i: usize, + env: _Environment, + ) -> Result { + let f = env.env().api().SessionGetOutputName.unwrap(); + extract_io_name(f, session_ptr, allocator_ptr, i, env) + } + + fn extract_io_name( + f: extern_system_fn! { unsafe fn( + *const sys::OrtSession, + usize, + *mut sys::OrtAllocator, + *mut *mut i8, + ) -> *mut sys::OrtStatus }, + session_ptr: *mut sys::OrtSession, + allocator_ptr: *mut sys::OrtAllocator, + i: usize, + env: _Environment, + ) -> Result { + let mut name_bytes: *mut i8 = std::ptr::null_mut(); + + let status = unsafe { f(session_ptr, i, allocator_ptr, &mut name_bytes) }; + status_to_result(status).map_err(OrtError::InputName)?; + assert_not_null_pointer(name_bytes, "InputName")?; + + let name = char_p_to_string(name_bytes)?; + + unsafe { + env.env().api().AllocatorFree.unwrap()( + allocator_ptr, + name_bytes as *mut std::ffi::c_void, + ) + }; + + Ok(name) + } + + pub(super) unsafe fn extract_input( + session_ptr: *mut sys::OrtSession, + allocator_ptr: *mut sys::OrtAllocator, + i: usize, + env: _Environment, + ) -> Result { + let input_name = extract_input_name(session_ptr, allocator_ptr, i, env.clone())?; + let f = env.env().api().SessionGetInputTypeInfo.unwrap(); + let (input_type, dimensions) = extract_io(f, session_ptr, i, env)?; + Ok(Input { + name: input_name, + input_type, + dimensions, + }) + } + + pub(super) unsafe fn extract_output( + session_ptr: *mut sys::OrtSession, + allocator_ptr: *mut sys::OrtAllocator, + i: usize, + env: _Environment, + ) -> Result { + let output_name = extract_output_name(session_ptr, allocator_ptr, i, env.clone())?; + let f = env.env().api().SessionGetOutputTypeInfo.unwrap(); + let (output_type, dimensions) = extract_io(f, session_ptr, i, env)?; + Ok(Output { + name: output_name, + output_type, + dimensions, + }) + } + + fn extract_io( + f: extern_system_fn! { unsafe fn( + *const sys::OrtSession, + usize, + *mut *mut sys::OrtTypeInfo, + ) -> *mut sys::OrtStatus }, + session_ptr: *mut sys::OrtSession, + i: usize, + env: _Environment, + ) -> Result<(TensorElementDataType, Vec>)> { + let mut typeinfo_ptr: *mut sys::OrtTypeInfo = std::ptr::null_mut(); + + let status = unsafe { f(session_ptr, i, &mut typeinfo_ptr) }; + status_to_result(status).map_err(OrtError::GetTypeInfo)?; + assert_not_null_pointer(typeinfo_ptr, "TypeInfo")?; + + let mut tensor_info_ptr: *const sys::OrtTensorTypeAndShapeInfo = std::ptr::null_mut(); + let status = unsafe { + env.env().api().CastTypeInfoToTensorInfo.unwrap()(typeinfo_ptr, &mut tensor_info_ptr) + }; + status_to_result(status).map_err(OrtError::CastTypeInfoToTensorInfo)?; + assert_not_null_pointer(tensor_info_ptr, "TensorInfo")?; + + let mut type_sys = sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED; + let status = unsafe { + env.env().api().GetTensorElementType.unwrap()(tensor_info_ptr, &mut type_sys) + }; + status_to_result(status).map_err(OrtError::TensorElementType)?; + (type_sys != sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED) + .then_some(()) + .ok_or(OrtError::UndefinedTensorElementType)?; + // This transmute should be safe since its value is read from GetTensorElementType which we must trust. + let io_type: TensorElementDataType = unsafe { std::mem::transmute(type_sys) }; + + // info!("{} : type={}", i, type_); + + let node_dims = unsafe { get_tensor_dimensions(tensor_info_ptr, env.clone())? }; + + // for j in 0..num_dims { + // info!("{} : dim {}={}", i, j, node_dims[j as usize]); + // } + + unsafe { env.env().api().ReleaseTypeInfo.unwrap()(typeinfo_ptr) }; + + Ok(( + io_type, + node_dims + .into_iter() + .map(|d| if d == -1 { None } else { Some(d as u32) }) + .collect(), + )) + } +} diff --git a/rust/onnxruntime/src/tensor.rs b/rust/onnxruntime/src/tensor.rs new file mode 100644 index 0000000000000..0f383f3ad59b6 --- /dev/null +++ b/rust/onnxruntime/src/tensor.rs @@ -0,0 +1,31 @@ +//! Module containing tensor types. +//! +//! Two main types of tensors are available. +//! +//! The first one, [`Tensor`](struct.Tensor.html), +//! is an _owned_ tensor that is backed by [`ndarray`](https://crates.io/crates/ndarray). +//! This kind of tensor is used to pass input data for the inference. +//! +//! The second one, [`OrtOwnedTensor`](struct.OrtOwnedTensor.html), is used +//! internally to pass to the ONNX Runtime inference execution to place +//! its output values. It is built using a [`OrtOwnedTensorExtractor`](struct.OrtOwnedTensorExtractor.html) +//! following the builder pattern. +//! +//! Once "extracted" from the runtime environment, this tensor will contain an +//! [`ndarray::ArrayView`](https://docs.rs/ndarray/latest/ndarray/type.ArrayView.html) +//! containing _a view_ of the data. When going out of scope, this tensor will free the required +//! memory on the C side. +//! +//! **NOTE**: Tensors are not meant to be built directly. When performing inference, +//! the [`Session::run()`](../session/struct.Session.html#method.run) method takes +//! an `ndarray::Array` as input (taking ownership of it) and will convert it internally +//! to a [`Tensor`](struct.Tensor.html). After inference, a [`OrtOwnedTensor`](struct.OrtOwnedTensor.html) +//! will be returned by the method which can be derefed into its internal +//! [`ndarray::ArrayView`](https://docs.rs/ndarray/latest/ndarray/type.ArrayView.html). + +pub mod construct; +pub mod ndarray_tensor; +pub mod ort_input_tensor; +pub mod ort_output_tensor; + +pub use ort_output_tensor::{OrtOutputTensor, WithOutputTensor}; diff --git a/rust/onnxruntime/src/tensor/construct.rs b/rust/onnxruntime/src/tensor/construct.rs new file mode 100644 index 0000000000000..97f70b131ea0a --- /dev/null +++ b/rust/onnxruntime/src/tensor/construct.rs @@ -0,0 +1,34 @@ +//! convert module has the trait for conversion of Inputs ConstructTensor. + +use crate::{memory::MemoryInfo, OrtError}; +use onnxruntime_sys::{OrtAllocator, OrtValue}; +use std::fmt::Debug; + +/// The Input type for Rust onnxruntime Session::run +pub trait ConstructTensor: Debug { + /// Constuct an OrtTensor Input using the `MemoryInfo` and a raw pointer to the `OrtAllocator`. + fn construct<'a>( + &'a mut self, + memory_info: &MemoryInfo, + allocator: *mut OrtAllocator, + ) -> Result, OrtError>; +} + +/// Allows the return value of ConstructTensor::construct +/// to be generic. +pub trait InputTensor { + /// The input tensor's shape + fn shape(&self) -> &[usize]; + + /// The input tensor's ptr + fn ptr(&self) -> *mut OrtValue; +} + +impl<'a, T> From for Box +where + T: ConstructTensor + 'a, +{ + fn from(other: T) -> Self { + Box::new(other) + } +} diff --git a/rust/onnxruntime/src/tensor/ndarray_tensor.rs b/rust/onnxruntime/src/tensor/ndarray_tensor.rs new file mode 100644 index 0000000000000..dea8d161b243b --- /dev/null +++ b/rust/onnxruntime/src/tensor/ndarray_tensor.rs @@ -0,0 +1,210 @@ +//! Module containing a tensor trait extending [`ndarray::ArrayBase`](https://docs.rs/ndarray/latest/ndarray/struct.ArrayBase.html) + +use ndarray::{Array, ArrayBase}; + +/// Trait extending [`ndarray::ArrayBase`](https://docs.rs/ndarray/latest/ndarray/struct.ArrayBase.html) +/// with useful tensor operations. +/// +/// # Generic +/// +/// The trait is generic over: +/// * `S`: [`ndarray::ArrayBase`](https://docs.rs/ndarray/latest/ndarray/struct.ArrayBase.html)'s data container +/// * `T`: Type contained inside the tensor (for example `f32`) +/// * `D`: Tensor's dimension ([`ndarray::Dimension`](https://docs.rs/ndarray/latest/ndarray/trait.Dimension.html)) +pub trait NdArrayTensor { + /// Calculate the [softmax](https://en.wikipedia.org/wiki/Softmax_function) of the tensor along a given axis + /// + /// # Trait Bounds + /// + /// The function is generic and thus has some trait bounds: + /// * `D: ndarray::RemoveAxis`: The summation over an axis reduces the dimension of the tensor. A 0-D tensor thus + /// cannot have a softmax calculated. + /// * `S: ndarray::RawData + ndarray::Data + ndarray::RawData`: The storage of the tensor can be an owned + /// array ([`ndarray::Array`](https://docs.rs/ndarray/latest/ndarray/type.Array.html)) or an array view + /// ([`ndarray::ArrayView`](https://docs.rs/ndarray/latest/ndarray/type.ArrayView.html)). + /// * `::Elem: std::clone::Clone`: The elements of the tensor must be `Clone`. + /// * `T: ndarray::NdFloat + std::ops::SubAssign + std::ops::DivAssign`: The elements of the tensor must be workable + /// as floats and must support `-=` and `/=` operations. + fn softmax(&self, axis: ndarray::Axis) -> Array + where + D: ndarray::RemoveAxis, + S: ndarray::Data + ndarray::RawData, + ::Elem: std::clone::Clone, + T: ndarray::NdFloat + std::ops::SubAssign + std::ops::DivAssign; +} + +impl NdArrayTensor for ArrayBase +where + D: ndarray::RemoveAxis, + S: ndarray::Data + ndarray::RawData, + ::Elem: std::clone::Clone, + T: ndarray::NdFloat + std::ops::SubAssign + std::ops::DivAssign, +{ + fn softmax(&self, axis: ndarray::Axis) -> Array { + let mut new_array: Array = self.to_owned(); + // FIXME: Change to non-overflowing formula + // e = np.exp(A - np.sum(A, axis=1, keepdims=True)) + // np.exp(a) / np.sum(np.exp(a)) + new_array.map_inplace(|v| *v = v.exp()); + let sum = new_array.sum_axis(axis).insert_axis(axis); + new_array /= ∑ + + new_array + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ndarray::{arr1, arr2, arr3}; + use test_log::test; + + #[test] + fn softmax_1d() { + let array = arr1(&[1.0_f32, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0]); + + let expected_softmax = arr1(&[ + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + 0.474_833, + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + ]); + + let softmax = array.softmax(ndarray::Axis(0)); + + assert_eq!(softmax.shape(), expected_softmax.shape()); + + let diff = softmax - expected_softmax; + + assert!(diff.iter().all(|d| d.abs() < 1.0e-7)); + } + + #[test] + fn softmax_2d() { + let array = arr2(&[ + [1.0_f32, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0], + [1.0_f32, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0], + ]); + + let expected_softmax = arr2(&[ + [ + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + 0.474_833, + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + ], + [ + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + 0.474_833, + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + ], + ]); + + let softmax = array.softmax(ndarray::Axis(1)); + + assert_eq!(softmax.shape(), expected_softmax.shape()); + + let diff = softmax - expected_softmax; + + assert!(diff.iter().all(|d| d.abs() < 1.0e-7)); + } + + #[test] + fn softmax_3d() { + let array = arr3(&[ + [ + [1.0_f32, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0], + [1.0_f32, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0], + ], + [ + [1.0_f32, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0], + [1.0_f32, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0], + ], + [ + [1.0_f32, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0], + [1.0_f32, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0], + ], + ]); + + let expected_softmax = arr3(&[ + [ + [ + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + 0.474_833, + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + ], + [ + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + 0.474_833, + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + ], + ], + [ + [ + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + 0.474_833, + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + ], + [ + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + 0.474_833, + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + ], + ], + [ + [ + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + 0.474_833, + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + ], + [ + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + 0.474_833, + 0.023_640_54, + 0.064_261_66, + 0.174_681_3, + ], + ], + ]); + + let softmax = array.softmax(ndarray::Axis(2)); + + assert_eq!(softmax.shape(), expected_softmax.shape()); + + let diff = softmax - expected_softmax; + + assert!(diff.iter().all(|d| d.abs() < 1.0e-7)); + } +} diff --git a/rust/onnxruntime/src/tensor/ort_input_tensor.rs b/rust/onnxruntime/src/tensor/ort_input_tensor.rs new file mode 100644 index 0000000000000..f2cf0ee8a1d4a --- /dev/null +++ b/rust/onnxruntime/src/tensor/ort_input_tensor.rs @@ -0,0 +1,325 @@ +//! Module containing tensor with memory owned by Rust + +use super::construct::{ConstructTensor, InputTensor}; +use crate::{ + environment::ENV, + error::{assert_not_null_pointer, call_ort, status_to_result}, + memory::MemoryInfo, + OrtError, Result, TensorElementDataType, TypeToTensorElementDataType, +}; +use ndarray::{Array, Dimension}; +use onnxruntime_sys as sys; +use std::{ffi, fmt::Debug}; +use sys::OrtAllocator; +use tracing::{debug, error}; + +/// An Input tensor. +/// +/// This ties the lifetime of T to the OrtValue; it is used to copy an +/// [`ndarray::Array`](https://docs.rs/ndarray/latest/ndarray/type.Array.html) to the runtime's memory. +/// +/// **NOTE**: The type is not meant to be used directly, use an [`ndarray::Array`](https://docs.rs/ndarray/latest/ndarray/type.Array.html) +/// instead. +#[derive(Debug)] +pub struct OrtInputTensor +where + T: Debug, +{ + pub(crate) c_ptr: *mut sys::OrtValue, + pub(crate) shape: Vec, + #[allow(dead_code)] + item: T, +} + +impl OrtInputTensor +where + T: Debug, +{ + /// The shape of the OrtTensor. + pub fn shape(&self) -> &[usize] { + &self.shape + } +} + +impl ConstructTensor for Array +where + T: TypeToTensorElementDataType + Debug, + D: Dimension, +{ + fn construct<'a>( + &'a mut self, + memory_info: &MemoryInfo, + allocator_ptr: *mut OrtAllocator, + ) -> Result> { + // where onnxruntime will write the tensor data to + let mut tensor_ptr: *mut sys::OrtValue = std::ptr::null_mut(); + let tensor_ptr_ptr: *mut *mut sys::OrtValue = &mut tensor_ptr; + + let sh = self.shape().to_vec(); + + let shape: Vec = self.shape().iter().map(|d: &usize| *d as i64).collect(); + let shape_ptr: *const i64 = shape.as_ptr(); + let shape_len = self.shape().len(); + + match T::tensor_element_data_type() { + TensorElementDataType::Float + | TensorElementDataType::Uint8 + | TensorElementDataType::Int8 + | TensorElementDataType::Uint16 + | TensorElementDataType::Int16 + | TensorElementDataType::Int32 + | TensorElementDataType::Int64 + | TensorElementDataType::Double + | TensorElementDataType::Uint32 + | TensorElementDataType::Uint64 => { + let buffer_size = self.len() * std::mem::size_of::(); + + // primitive data is already suitably laid out in memory; provide it to + // onnxruntime as is + let tensor_values_ptr: *mut std::ffi::c_void = + self.as_mut_ptr().cast::(); + + assert_not_null_pointer(tensor_values_ptr, "TensorValues")?; + + unsafe { + call_ort(|ort| { + ort.CreateTensorWithDataAsOrtValue.unwrap()( + memory_info.ptr, + tensor_values_ptr, + buffer_size, + shape_ptr, + shape_len, + T::tensor_element_data_type().into(), + tensor_ptr_ptr, + ) + }) + } + .map_err(OrtError::CreateTensorWithData)?; + assert_not_null_pointer(tensor_ptr, "Tensor")?; + + let mut is_tensor = 0; + let status = unsafe { + ENV.get().unwrap().lock().unwrap().api().IsTensor.unwrap()( + tensor_ptr, + &mut is_tensor, + ) + }; + status_to_result(status).map_err(OrtError::IsTensor)?; + } + TensorElementDataType::String => { + // create tensor without data -- data is filled in later + unsafe { + call_ort(|ort| { + ort.CreateTensorAsOrtValue.unwrap()( + allocator_ptr, + shape_ptr, + shape_len, + T::tensor_element_data_type().into(), + tensor_ptr_ptr, + ) + }) + } + .map_err(OrtError::CreateTensor)?; + + // create null-terminated copies of each string, as per `FillStringTensor` docs + let null_terminated_copies: Vec = self + .iter() + .map(|elt| { + let slice = elt + .try_utf8_bytes() + .expect("String data type must provide utf8 bytes"); + ffi::CString::new(slice) + }) + .collect::, _>>() + .map_err(OrtError::CStringNulError)?; + + let string_pointers = null_terminated_copies + .iter() + .map(|cstring| cstring.as_ptr()) + .collect::>(); + + unsafe { + call_ort(|ort| { + ort.FillStringTensor.unwrap()( + tensor_ptr, + string_pointers.as_ptr(), + string_pointers.len(), + ) + }) + } + .map_err(OrtError::FillStringTensor)?; + } + } + + assert_not_null_pointer(tensor_ptr, "Tensor")?; + + Ok(Box::new(OrtInputTensor { + c_ptr: tensor_ptr, + shape: sh, + item: self, + })) + } +} + +impl Drop for OrtInputTensor +where + T: Debug, +{ + #[tracing::instrument] + fn drop(&mut self) { + // We need to let the C part free + debug!("Dropping Tensor."); + if self.c_ptr.is_null() { + error!("Null pointer, not calling free."); + } else { + unsafe { + ENV.get() + .unwrap() + .lock() + .unwrap() + .api() + .ReleaseValue + .unwrap()(self.c_ptr) + } + } + + self.c_ptr = std::ptr::null_mut(); + } +} + +impl InputTensor for OrtInputTensor<&mut Array> +where + T: TypeToTensorElementDataType + Debug, + D: Dimension, +{ + fn ptr(&self) -> *mut sys::OrtValue { + self.c_ptr + } + + fn shape(&self) -> &[usize] { + &self.shape + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + environment::{tests::ONNX_RUNTIME_LIBRARY_PATH, Environment}, + AllocatorType, LoggingLevel, MemType, + }; + use ndarray::{arr0, arr1, arr2, arr3}; + use once_cell::sync::Lazy; + use std::env::var; + use test_log::test; + + static ENV: Lazy = Lazy::new(|| { + let path = var(ONNX_RUNTIME_LIBRARY_PATH).ok(); + + let builder = Environment::builder() + .with_name("test") + .with_log_level(LoggingLevel::Warning); + let builder = if let Some(path) = path { + builder.with_library_path(path) + } else { + builder + }; + + builder.build().unwrap() + }); + + #[test] + fn orttensor_from_array_0d_i32() { + let env = &*ENV; + + let memory_info = MemoryInfo::new(AllocatorType::Arena, MemType::Default, env).unwrap(); + let mut array = arr0::(123); + let tensor = array + .construct(&memory_info, ort_default_allocator()) + .unwrap(); + let expected_shape: &[usize] = &[]; + assert_eq!(tensor.shape(), expected_shape); + } + + #[test] + fn orttensor_from_array_1d_i32() { + let env = &*ENV; + + let memory_info = MemoryInfo::new(AllocatorType::Arena, MemType::Default, env).unwrap(); + let mut array = arr1(&[1_i32, 2, 3, 4, 5, 6]); + let tensor = array + .construct(&memory_info, ort_default_allocator()) + .unwrap(); + let expected_shape: &[usize] = &[6]; + assert_eq!(tensor.shape(), expected_shape); + } + + #[test] + fn orttensor_from_array_2d_i32() { + let env = &*ENV; + + let memory_info = MemoryInfo::new(AllocatorType::Arena, MemType::Default, env).unwrap(); + let mut array = arr2(&[[1_i32, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]]); + let tensor = array + .construct(&memory_info, ort_default_allocator()) + .unwrap(); + assert_eq!(tensor.shape(), &[2, 6]); + } + + #[test] + fn orttensor_from_array_3d_i32() { + let env = &*ENV; + + let memory_info = MemoryInfo::new(AllocatorType::Arena, MemType::Default, env).unwrap(); + let mut array = arr3(&[ + [[1_i32, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]], + [[13, 14, 15, 16, 17, 18], [19, 20, 21, 22, 23, 24]], + [[25, 26, 27, 28, 29, 30], [31, 32, 33, 34, 35, 36]], + ]); + let tensor = array + .construct(&memory_info, ort_default_allocator()) + .unwrap(); + assert_eq!(tensor.shape(), &[3, 2, 6]); + } + + #[test] + fn orttensor_from_array_1d_string() { + let env = &*ENV; + + let memory_info = MemoryInfo::new(AllocatorType::Arena, MemType::Default, env).unwrap(); + let mut array = arr1(&[ + String::from("foo"), + String::from("bar"), + String::from("baz"), + ]); + let tensor = array + .construct(&memory_info, ort_default_allocator()) + .unwrap(); + assert_eq!(tensor.shape(), &[3]); + } + + #[test] + fn orttensor_from_array_3d_str() { + let env = &*ENV; + + let memory_info = MemoryInfo::new(AllocatorType::Arena, MemType::Default, env).unwrap(); + let mut array = arr3(&[ + [["1", "2", "3"], ["4", "5", "6"]], + [["7", "8", "9"], ["10", "11", "12"]], + ]); + let tensor = array + .construct(&memory_info, ort_default_allocator()) + .unwrap(); + assert_eq!(tensor.shape(), &[2, 2, 3]); + } + + fn ort_default_allocator() -> *mut sys::OrtAllocator { + let mut allocator_ptr: *mut sys::OrtAllocator = std::ptr::null_mut(); + unsafe { + // this default non-arena allocator doesn't need to be deallocated + call_ort(|ort| ort.GetAllocatorWithDefaultOptions.unwrap()(&mut allocator_ptr)) + } + .unwrap(); + allocator_ptr + } +} diff --git a/rust/onnxruntime/src/tensor/ort_output_tensor.rs b/rust/onnxruntime/src/tensor/ort_output_tensor.rs new file mode 100644 index 0000000000000..5176a58c423ea --- /dev/null +++ b/rust/onnxruntime/src/tensor/ort_output_tensor.rs @@ -0,0 +1,347 @@ +//! Module containing tensor with memory owned by the ONNX Runtime + +use crate::{ + environment::{_Environment, ENV}, + error::status_to_result, + OrtError, Result, TypeToTensorElementDataType, +}; +use ndarray::ArrayView; +use onnxruntime_sys as sys; + +use std::{convert::TryFrom, fmt::Debug}; +use tracing::debug; + +/// Tensor containing data owned by the ONNX Runtime C library, used to return values from inference. +/// +/// This tensor type is returned by the [`Session::run()`](../session/struct.Session.html#method.run) method. +/// It is not meant to be created directly. +#[derive(Debug)] +pub struct OrtOutputTensor { + pub(crate) tensor_ptr: *mut sys::OrtValue, + pub(crate) shape: Vec, + env: _Environment, +} + +#[derive(Debug)] +pub(crate) struct OrtOwnedTensorExtractor { + pub(crate) tensor_ptr: *mut sys::OrtValue, + pub(crate) shape: Vec, + env: _Environment, +} + +impl OrtOwnedTensorExtractor { + pub(crate) fn new(shape: Vec, env: _Environment) -> OrtOwnedTensorExtractor { + OrtOwnedTensorExtractor { + tensor_ptr: std::ptr::null_mut(), + shape, + env, + } + } + + pub(crate) fn extract(self) -> Result { + // Note: Both tensor and array will point to the same data, nothing is copied. + // As such, there is no need too free the pointer used to create the ArrayView. + + assert_ne!(self.tensor_ptr, std::ptr::null_mut()); + + let mut is_tensor = 0; + let status = + unsafe { self.env.env().api().IsTensor.unwrap()(self.tensor_ptr, &mut is_tensor) }; + status_to_result(status).map_err(OrtError::IsTensor)?; + (is_tensor == 1) + .then_some(()) + .ok_or(OrtError::IsTensorCheck)?; + + Ok(OrtOutputTensor { + tensor_ptr: self.tensor_ptr, + shape: self.shape, + env: self.env, + }) + } +} + +impl Drop for OrtOutputTensor { + #[tracing::instrument] + fn drop(&mut self) { + debug!("Dropping OrtOwnedTensor."); + unsafe { self.env.env().api().ReleaseValue.unwrap()(self.tensor_ptr) } + + self.tensor_ptr = std::ptr::null_mut(); + } +} + +/// An Ouput tensor with the ptr and the item that will copy from the ptr. +#[derive(Debug)] +pub struct WithOutputTensor<'a, T> { + #[allow(dead_code)] + pub(crate) tensor: OrtOutputTensor, + item: ArrayView<'a, T, ndarray::IxDyn>, +} + +impl<'a, T> std::ops::Deref for WithOutputTensor<'a, T> { + type Target = ArrayView<'a, T, ndarray::IxDyn>; + + fn deref(&self) -> &Self::Target { + &self.item + } +} + +impl<'a, T> TryFrom for WithOutputTensor<'a, T> +where + T: TypeToTensorElementDataType, +{ + type Error = OrtError; + + fn try_from(value: OrtOutputTensor) -> Result { + // Get pointer to output tensor float values + let mut output_array_ptr: *mut T = std::ptr::null_mut(); + let output_array_ptr_ptr: *mut *mut T = &mut output_array_ptr; + let output_array_ptr_ptr_void: *mut *mut std::ffi::c_void = + output_array_ptr_ptr.cast::<*mut std::ffi::c_void>(); + let status = unsafe { + ENV.get() + .unwrap() + .lock() + .unwrap() + .api() + .GetTensorMutableData + .unwrap()(value.tensor_ptr, output_array_ptr_ptr_void) + }; + status_to_result(status).map_err(OrtError::IsTensor)?; + assert_ne!(output_array_ptr, std::ptr::null_mut()); + + let array_view = + unsafe { ArrayView::from_shape_ptr(ndarray::IxDyn(&value.shape), output_array_ptr) }; + + Ok(WithOutputTensor { + tensor: value, + item: array_view, + }) + } +} + +/// The onnxruntime Run output type. +pub enum OrtOutput<'a> { + /// Tensor of f32s + Float(WithOutputTensor<'a, f32>), + /// Tensor of f64s + Double(WithOutputTensor<'a, f64>), + /// Tensor of u8s + UInt8(WithOutputTensor<'a, u8>), + /// Tensor of u16s + UInt16(WithOutputTensor<'a, u16>), + /// Tensor of u32s + UInt32(WithOutputTensor<'a, u32>), + /// Tensor of u64s + UInt64(WithOutputTensor<'a, u64>), + /// Tensor of i8s + Int8(WithOutputTensor<'a, i8>), + /// Tensor of i16s + Int16(WithOutputTensor<'a, i16>), + /// Tensor of i32s + Int32(WithOutputTensor<'a, i32>), + /// Tensor of i64s + Int64(WithOutputTensor<'a, i64>), + /// Tensor of Strings + String(WithOutputTensor<'a, String>), +} + +impl<'a> OrtOutput<'a> { + /// Return `WithOutputTensor<'a, f32>` which derefs into an `ArrayView`. + pub fn float_array(&self) -> Option<&WithOutputTensor<'a, f32>> { + if let Self::Float(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, f64>` which derefs into an `ArrayView`. + pub fn double_array(&self) -> Option<&WithOutputTensor<'a, f64>> { + if let Self::Double(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, u8>` which derefs into an `ArrayView`. + pub fn uint8_array(&self) -> Option<&WithOutputTensor<'a, u8>> { + if let Self::UInt8(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, u16>` which derefs into an `ArrayView`. + pub fn uint16_array(&self) -> Option<&WithOutputTensor<'a, u16>> { + if let Self::UInt16(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, u32>` which derefs into an `ArrayView`. + pub fn uint32_array(&self) -> Option<&WithOutputTensor<'a, u32>> { + if let Self::UInt32(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, u64>` which derefs into an `ArrayView`. + pub fn uint64_array(&self) -> Option<&WithOutputTensor<'a, u64>> { + if let Self::UInt64(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, i8>` which derefs into an `ArrayView`. + pub fn int8_array(&self) -> Option<&WithOutputTensor<'a, i8>> { + if let Self::Int8(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, i16>` which derefs into an `ArrayView`. + pub fn int16_array(&self) -> Option<&WithOutputTensor<'a, i16>> { + if let Self::Int16(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, i32>` which derefs into an `ArrayView`. + pub fn int32_array(&self) -> Option<&WithOutputTensor<'a, i32>> { + if let Self::Int32(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, i64>` which derefs into an `ArrayView`. + pub fn int64_array(&self) -> Option<&WithOutputTensor<'a, i64>> { + if let Self::Int64(item) = self { + Some(item) + } else { + None + } + } + + /// Return `WithOutputTensor<'a, String>` which derefs into an `ArrayView`. + pub fn string_array(&self) -> Option<&WithOutputTensor<'a, String>> { + if let Self::String(item) = self { + Some(item) + } else { + None + } + } +} + +impl<'a> TryFrom for OrtOutput<'a> { + type Error = OrtError; + + fn try_from(value: OrtOutputTensor) -> Result> { + unsafe { + let mut shape_info = std::ptr::null_mut(); + + let status = ENV + .get() + .unwrap() + .lock() + .unwrap() + .api() + .GetTensorTypeAndShape + .unwrap()(value.tensor_ptr, &mut shape_info); + + status_to_result(status).map_err(OrtError::IsTensor)?; + + assert_ne!(shape_info, std::ptr::null_mut()); + + let mut element_type = + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED; + + let status = ENV + .get() + .unwrap() + .lock() + .unwrap() + .api() + .GetTensorElementType + .unwrap()(shape_info, &mut element_type); + + status_to_result(status).map_err(OrtError::IsTensor)?; + + ENV.get() + .unwrap() + .lock() + .unwrap() + .api() + .ReleaseTensorTypeAndShapeInfo + .unwrap()(shape_info); + + match element_type { + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED => { + unimplemented!() + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT => { + WithOutputTensor::try_from(value).map(OrtOutput::Float) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT8 => { + WithOutputTensor::try_from(value).map(OrtOutput::UInt8) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT8 => { + WithOutputTensor::try_from(value).map(OrtOutput::Int8) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT16 => { + WithOutputTensor::try_from(value).map(OrtOutput::UInt16) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT16 => { + WithOutputTensor::try_from(value).map(OrtOutput::Int16) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT32 => { + WithOutputTensor::try_from(value).map(OrtOutput::Int32) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64 => { + WithOutputTensor::try_from(value).map(OrtOutput::Int64) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_STRING => { + WithOutputTensor::try_from(value).map(OrtOutput::String) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_BOOL => { + unimplemented!() + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16 => { + unimplemented!() + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_DOUBLE => { + WithOutputTensor::try_from(value).map(OrtOutput::Double) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT32 => { + WithOutputTensor::try_from(value).map(OrtOutput::UInt32) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_UINT64 => { + WithOutputTensor::try_from(value).map(OrtOutput::UInt64) + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX64 => { + unimplemented!() + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_COMPLEX128 => { + unimplemented!() + } + sys::ONNXTensorElementDataType::ONNX_TENSOR_ELEMENT_DATA_TYPE_BFLOAT16 => { + unimplemented!() + } + } + } + } +} diff --git a/rust/onnxruntime/tests/data/mnist_5.jpg b/rust/onnxruntime/tests/data/mnist_5.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2216a276c4c0a687b9c437791b555e9a2132e596 GIT binary patch literal 555 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 S* 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 literal 0 HcmV?d00001 diff --git a/rust/onnxruntime/tests/data/upsample.onnx b/rust/onnxruntime/tests/data/upsample.onnx new file mode 100644 index 0000000000000000000000000000000000000000..43b2596edcbbd495ee0a8bfb0eb6cd1d049b394b GIT binary patch literal 1861 zcmbVN&2G~`5Y9Sj>`YS{ERd>7l*$qZ3~C&wzd1!I9Ej8k6(j_gl^btb3wv$sO({LW zoAeIPz$@?!ya}^*T-Pm?lIAqyo%v>FXTL9+s;gpbhn%0UE_60_HtgQ72k-$NPa>K) zLF9Y<#CEC2qlxHQ`ttkO;Yk!GjMC0_&stq7eJ~nVuK5sZ5sQO(*Y2`}W#-FJMyov<$x#)aqj?b3C$v(~KDjrlUBGr>;+bJxbX!ds#OnjX0<;?S8yiq-v`0x?tETV&4JQ8OM3u?Jt zdJZZ+2V~F?37PY%XTh~Ry@?Z!9El>u0SeEkXSTTrHRs$*Rw^0RZ*of!K#fBWxoj6k z307|&vBbNO9N&>e3eTu#wz&suqcDtJk2``zGOHws;sHFiaXHLu#A-}CabLwTu3u7PA@dPj92Z2&QI=peEW{7Xt+v}P4hNW<% zcGmx4R{D^6C#T0@{C`zdq^_o_dZpmzvQ}x4L4JS3in*qdmO8j0nM18I{h3eF^Rk2v zH6=eTrRPK*Y9v2@nx@|*_E4+l0ewwr)d#bS-IO3%3$l$PTqHkZv0(1{I1Yn#5y(HL zGbF!UlmF?}U26UUo@+#Z1`S=E@XwTXJB4W%W)~E#db|hM)~4S|-7Mm_3Ugbg4wP2& EACDAH$^ZZW literal 0 HcmV?d00001 diff --git a/rust/onnxruntime/tests/integration_tests.rs b/rust/onnxruntime/tests/integration_tests.rs new file mode 100644 index 0000000000000..7843fe269e5e4 --- /dev/null +++ b/rust/onnxruntime/tests/integration_tests.rs @@ -0,0 +1,555 @@ +use onnxruntime::{error::OrtDownloadError, tensor::ndarray_tensor::NdArrayTensor}; +use std::{ + fs, + io::{self, BufRead, BufReader}, + path::Path, + sync::Arc, + time::Duration, +}; + +mod download { + use std::env::var; + + use super::*; + const RUST_ONNXRUNTIME_LIBRARY_PATH: &str = "RUST_ONNXRUNTIME_LIBRARY_PATH"; + + use image::{imageops::FilterType, ImageBuffer, Luma, Pixel, Rgb}; + use ndarray::s; + use test_log::test; + + use onnxruntime::{ + download::vision::{DomainBasedImageClassification, ImageClassification}, + environment::Environment, + GraphOptimizationLevel, LoggingLevel, + }; + + #[test] + fn squeezenet_mushroom() { + const IMAGE_TO_LOAD: &str = "mushroom.png"; + + let path = var(RUST_ONNXRUNTIME_LIBRARY_PATH).ok(); + + let environment = { + let builder = Environment::builder() + .with_name("integration_test") + .with_log_level(LoggingLevel::Warning); + let builder = if let Some(path) = path { + builder.with_library_path(path) + } else { + builder + }; + + builder.build().unwrap() + }; + let session = environment + .new_session_builder() + .unwrap() + .with_graph_optimization_level(GraphOptimizationLevel::Basic) + .unwrap() + .with_intra_op_num_threads(1) + .unwrap() + .with_model_downloaded(ImageClassification::SqueezeNet) + .expect("Could not download model from file"); + + let class_labels = get_imagenet_labels().unwrap(); + + let input0_shape: Vec = session.inputs[0].dimensions().map(|d| d.unwrap()).collect(); + let output0_shape: Vec = session.outputs[0] + .dimensions() + .map(|d| d.unwrap()) + .collect(); + + assert_eq!(input0_shape, [1, 3, 224, 224]); + assert_eq!(output0_shape, [1, 1000]); + + // Load image and resize to model's shape, converting to RGB format + let image_buffer: ImageBuffer, Vec> = image::open( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("data") + .join(IMAGE_TO_LOAD), + ) + .unwrap() + .resize( + input0_shape[2] as u32, + input0_shape[3] as u32, + FilterType::Nearest, + ) + .to_rgb8(); + + // Python: + // # image[y, x, RGB] + // # x==0 --> left + // # y==0 --> top + + // See https://github.com/onnx/models/blob/main/vision/classification/imagenet_inference.ipynb + // for pre-processing image. + // WARNING: Note order of declaration of arguments: (_,c,j,i) + let mut array = ndarray::Array::from_shape_fn((1, 3, 224, 224), |(_, c, j, i)| { + let pixel = image_buffer.get_pixel(i as u32, j as u32); + let channels = pixel.channels(); + + // range [0, 255] -> range [0, 1] + (channels[c] as f32) / 255.0 + }); + + // Normalize channels to mean=[0.485, 0.456, 0.406] and std=[0.229, 0.224, 0.225] + let mean = [0.485, 0.456, 0.406]; + let std = [0.229, 0.224, 0.225]; + for c in 0..3 { + let mut channel_array = array.slice_mut(s![0, c, .., ..]); + channel_array -= mean[c]; + channel_array /= std[c]; + } + + // Batch of 1 + let input_tensor_values = vec![array.into()]; + + // Perform the inference + let outputs = session.run(input_tensor_values).unwrap(); + + // Downloaded model does not have a softmax as final layer; call softmax on second axis + // and iterate on resulting probabilities, creating an index to later access labels. + let output = outputs[0].float_array().unwrap(); + let mut probabilities: Vec<(usize, f32)> = output + .softmax(ndarray::Axis(1)) + .iter() + .copied() + .enumerate() + .collect::>(); + // Sort probabilities so highest is at beginning of vector. + probabilities.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + + assert_eq!( + class_labels[probabilities[0].0], "n07734744 mushroom", + "Expecting class for {} to be a mushroom", + IMAGE_TO_LOAD + ); + + assert_eq!( + probabilities[0].0, 947, + "Expecting class for {} to be a mushroom (index 947 in labels file)", + IMAGE_TO_LOAD + ); + + // for i in 0..5 { + // println!( + // "class={} ({}); probability={}", + // labels[probabilities[i].0], probabilities[i].0, probabilities[i].1 + // ); + // } + } + + #[test] + fn mnist_5() { + const IMAGE_TO_LOAD: &str = "mnist_5.jpg"; + + let path = var(RUST_ONNXRUNTIME_LIBRARY_PATH).ok(); + + let environment = { + let builder = Environment::builder() + .with_name("integration_test") + .with_log_level(LoggingLevel::Warning); + let builder = if let Some(path) = path { + builder.with_library_path(path) + } else { + builder + }; + + builder.build().unwrap() + }; + + let session = environment + .new_session_builder() + .unwrap() + .with_graph_optimization_level(GraphOptimizationLevel::Basic) + .unwrap() + .with_intra_op_num_threads(1) + .unwrap() + .with_model_downloaded(DomainBasedImageClassification::Mnist) + .expect("Could not download model from file"); + + let input0_shape: Vec = session.inputs[0].dimensions().map(|d| d.unwrap()).collect(); + let output0_shape: Vec = session.outputs[0] + .dimensions() + .map(|d| d.unwrap()) + .collect(); + + assert_eq!(input0_shape, [1, 1, 28, 28]); + assert_eq!(output0_shape, [1, 10]); + + // Load image and resize to model's shape, converting to RGB format + let image_buffer: ImageBuffer, Vec> = image::open( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("data") + .join(IMAGE_TO_LOAD), + ) + .unwrap() + .resize( + input0_shape[2] as u32, + input0_shape[3] as u32, + FilterType::Nearest, + ) + .to_luma8(); + + let array = ndarray::Array::from_shape_fn((1, 1, 28, 28), |(_, c, j, i)| { + let pixel = image_buffer.get_pixel(i as u32, j as u32); + let channels = pixel.channels(); + + // range [0, 255] -> range [0, 1] + (channels[c] as f32) / 255.0 + }); + + // Batch of 1 + let input_tensor_values = vec![array.into()]; + + // Perform the inference + let outputs = session.run(input_tensor_values).unwrap(); + + let output = outputs[0].float_array().unwrap(); + let mut probabilities: Vec<(usize, f32)> = output + .softmax(ndarray::Axis(1)) + .iter() + .copied() + .enumerate() + .collect::>(); + + // Sort probabilities so highest is at beginning of vector. + probabilities.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + + assert_eq!( + probabilities[0].0, 5, + "Expecting class for {} is '5' (not {})", + IMAGE_TO_LOAD, probabilities[0].0 + ); + } + + #[test] + fn mnist_5_concurrent_session() { + const IMAGE_TO_LOAD: &str = "mnist_5.jpg"; + + let path = var(RUST_ONNXRUNTIME_LIBRARY_PATH).ok(); + + let environment = { + let builder = Environment::builder() + .with_name("integration_test") + .with_log_level(LoggingLevel::Warning); + let builder = if let Some(path) = path { + builder.with_library_path(path) + } else { + builder + }; + + builder.build().unwrap() + }; + + let session = Arc::new( + environment + .new_session_builder() + .unwrap() + .with_graph_optimization_level(GraphOptimizationLevel::Basic) + .unwrap() + .with_intra_op_num_threads(1) + .unwrap() + .with_model_downloaded(DomainBasedImageClassification::Mnist) + .expect("Could not download model from file"), + ); + + let children: Vec> = (0..20) + .map(move |_| { + let session = session.clone(); + std::thread::spawn(move || { + let input0_shape: Vec = + session.inputs[0].dimensions().map(|d| d.unwrap()).collect(); + let output0_shape: Vec = session.outputs[0] + .dimensions() + .map(|d| d.unwrap()) + .collect(); + + assert_eq!(input0_shape, [1, 1, 28, 28]); + assert_eq!(output0_shape, [1, 10]); + + // Load image and resize to model's shape, converting to RGB format + let image_buffer: ImageBuffer, Vec> = image::open( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("data") + .join(IMAGE_TO_LOAD), + ) + .unwrap() + .resize( + input0_shape[2] as u32, + input0_shape[3] as u32, + FilterType::Nearest, + ) + .to_luma8(); + + let array = ndarray::Array::from_shape_fn((1, 1, 28, 28), |(_, c, j, i)| { + let pixel = image_buffer.get_pixel(i as u32, j as u32); + let channels = pixel.channels(); + + // range [0, 255] -> range [0, 1] + (channels[c] as f32) / 255.0 + }); + + // Batch of 1 + let input_tensor_values = vec![array.into()]; + + // Perform the inference + let outputs = session.run(input_tensor_values).unwrap(); + + let output = &outputs[0].float_array().unwrap(); + let mut probabilities: Vec<(usize, f32)> = output + .softmax(ndarray::Axis(1)) + .iter() + .copied() + .enumerate() + .collect::>(); + + // Sort probabilities so highest is at beginning of vector. + probabilities.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + + assert_eq!( + probabilities[0].0, 5, + "Expecting class for {} is '5' (not {})", + IMAGE_TO_LOAD, probabilities[0].0 + ); + }) + }) + .collect(); + + assert!(children + .into_iter() + .map(std::thread::JoinHandle::join) + .collect::, _>>() + .is_ok()); + } + + #[test] + fn mnist_5_send_session() { + const IMAGE_TO_LOAD: &str = "mnist_5.jpg"; + + let path = var(RUST_ONNXRUNTIME_LIBRARY_PATH).ok(); + + let environment = { + let builder = Environment::builder() + .with_name("integration_test") + .with_log_level(LoggingLevel::Warning); + let builder = if let Some(path) = path { + builder.with_library_path(path) + } else { + builder + }; + + builder.build().unwrap() + }; + + let children: Vec> = (0..20) + .map(|_| { + let session = environment + .new_session_builder() + .unwrap() + .with_graph_optimization_level(GraphOptimizationLevel::Basic) + .unwrap() + .with_intra_op_num_threads(1) + .unwrap() + .with_model_downloaded(DomainBasedImageClassification::Mnist) + .expect("Could not download model from file"); + std::thread::spawn(move || { + let input0_shape: Vec = + session.inputs[0].dimensions().map(|d| d.unwrap()).collect(); + let output0_shape: Vec = session.outputs[0] + .dimensions() + .map(|d| d.unwrap()) + .collect(); + + assert_eq!(input0_shape, [1, 1, 28, 28]); + assert_eq!(output0_shape, [1, 10]); + + // Load image and resize to model's shape, converting to RGB format + let image_buffer: ImageBuffer, Vec> = image::open( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("data") + .join(IMAGE_TO_LOAD), + ) + .unwrap() + .resize( + input0_shape[2] as u32, + input0_shape[3] as u32, + FilterType::Nearest, + ) + .to_luma8(); + + let array = ndarray::Array::from_shape_fn((1, 1, 28, 28), |(_, c, j, i)| { + let pixel = image_buffer.get_pixel(i as u32, j as u32); + let channels = pixel.channels(); + + // range [0, 255] -> range [0, 1] + (channels[c] as f32) / 255.0 + }); + + // Batch of 1 + let input_tensor_values = vec![array.into()]; + + // Perform the inference + let outputs = session.run(input_tensor_values).unwrap(); + + let output = &outputs[0].float_array().unwrap(); + let mut probabilities: Vec<(usize, f32)> = output + .softmax(ndarray::Axis(1)) + .iter() + .copied() + .enumerate() + .collect::>(); + + // Sort probabilities so highest is at beginning of vector. + probabilities.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + + assert_eq!( + probabilities[0].0, 5, + "Expecting class for {} is '5' (not {})", + IMAGE_TO_LOAD, probabilities[0].0 + ); + }) + }) + .collect(); + + assert!(children + .into_iter() + .map(std::thread::JoinHandle::join) + .collect::, _>>() + .is_ok()); + } + + // This test verifies that dynamically sized inputs and outputs work. It loads and runs + // upsample.onnx, which was produced via: + // + // ``` + // import subprocess + // from tensorflow import keras + // + // m = keras.Sequential([ + // keras.layers.UpSampling2D(size=2) + // ]) + // m.build(input_shape=(None, None, None, 3)) + // m.summary() + // m.save('saved_model') + // + // subprocess.check_call([ + // 'python', '-m', 'tf2onnx.convert', + // '--saved-model', 'saved_model', + // '--opset', '12', + // '--output', 'upsample.onnx', + // ]) + // ``` + #[test] + fn upsample() { + const IMAGE_TO_LOAD: &str = "mushroom.png"; + + let path = var(RUST_ONNXRUNTIME_LIBRARY_PATH).ok(); + + let environment = { + let builder = Environment::builder() + .with_name("integration_test") + .with_log_level(LoggingLevel::Warning); + let builder = if let Some(path) = path { + builder.with_library_path(path) + } else { + builder + }; + + builder.build().unwrap() + }; + + let session = environment + .new_session_builder() + .unwrap() + .with_graph_optimization_level(GraphOptimizationLevel::Basic) + .unwrap() + .with_intra_op_num_threads(1) + .unwrap() + .with_model_from_file( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("data") + .join("upsample.onnx"), + ) + .expect("Could not open model from file"); + + assert_eq!( + session.inputs[0].dimensions().collect::>(), + [None, None, None, Some(3)] + ); + assert_eq!( + session.outputs[0].dimensions().collect::>(), + [None, None, None, Some(3)] + ); + + // Load image, converting to RGB format + let image_buffer: ImageBuffer, Vec> = image::open( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("data") + .join(IMAGE_TO_LOAD), + ) + .unwrap() + .to_rgb8(); + + let array = ndarray::Array::from_shape_fn((1, 224, 224, 3), |(_, j, i, c)| { + let pixel = image_buffer.get_pixel(i as u32, j as u32); + let channels = pixel.channels(); + + // range [0, 255] -> range [0, 1] + (channels[c] as f32) / 255.0 + }); + + // Just one input + let input_tensor_values = vec![array.into()]; + + // Perform the inference + let outputs = session.run(input_tensor_values).unwrap(); + + assert_eq!(outputs.len(), 1); + let output = outputs[0].float_array().unwrap(); + + // The image should have doubled in size + assert_eq!(output.shape(), [1, 448, 448, 3]); + } +} + +fn get_imagenet_labels() -> Result, OrtDownloadError> { + // Download the ImageNet class labels, matching SqueezeNet's classes. + let labels_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("synset.txt"); + if !labels_path.exists() { + let url = "https://s3.amazonaws.com/onnx-model-zoo/synset.txt"; + println!("Downloading {:?} to {:?}...", url, labels_path); + let resp = ureq::get(url) + .timeout(Duration::from_secs(180)) // 3 minutes + .call() + .map_err(Box::new) + .map_err(OrtDownloadError::UreqError)?; + + assert!(resp.has("Content-Length")); + let len = resp + .header("Content-Length") + .and_then(|s| s.parse::().ok()) + .unwrap(); + println!("Downloading {} bytes...", len); + + let mut reader = resp.into_reader(); + + let f = fs::File::create(&labels_path).unwrap(); + let mut writer = io::BufWriter::new(f); + + let bytes_io_count = io::copy(&mut reader, &mut writer).unwrap(); + + assert_eq!(bytes_io_count, len as u64); + } + let file = BufReader::new(fs::File::open(labels_path).unwrap()); + + file.lines() + .map(|line| line.map_err(|io_err| OrtDownloadError::IoError(io_err))) + .collect() +} diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml new file mode 100644 index 0000000000000..267219dda5f37 --- /dev/null +++ b/rust/rustfmt.toml @@ -0,0 +1,2 @@ +format_code_in_doc_comments = true +imports_granularity = "Crate"