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

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


The following commit(s) were added to refs/heads/main by this push:
     new 2cad1f29f GH-1389, GH-1390: Let SentenceVectorsDL run on a GPU and fix 
the first CUDA request in a fresh JVM (#1462)
2cad1f29f is described below

commit 2cad1f29ff8d980c0faaacec7e901af919531e22
Author: Martin Wiesner <[email protected]>
AuthorDate: Sun Oct 18 14:32:09 2026 +0200

    GH-1389, GH-1390: Let SentenceVectorsDL run on a GPU and fix the first CUDA 
request in a fresh JVM (#1462)
    
    SentenceVectorsDL always created default session options, so it ran on
    the CPU even with opennlp-dl-gpu. A new constructor takes
    InferenceOptions, like NameFinderDL and DocumentCategorizerDL, and uses
    the GPU settings and the lower case setting from them; a lower case
    setting in the options takes precedence over the lowerCase argument. The
    existing constructors keep their behavior.
    
    AbstractDL.sessionOptions added the CUDA provider before any
    OrtEnvironment existed. Loading the provider needs ONNX Runtime's default
    logger, which only exists once an environment does, so the first GPU
    request in a fresh JVM failed with "Attempt to use DefaultLogger but none
    has been registered". The environment is now created first. This affects
    NameFinderDL and DocumentCategorizerDL as well. When the provider cannot
    be added, the session options created for the request are closed before
    the exception is thrown.
    
    A test makes the first CUDA request of a child JVM and requires that the
    provider is added or rejected for a reason other than the missing
    default logger; it runs in opennlp-dl-gpu and fails with the AbstractDL
    of main. Other tests request a device id no machine has and check that ONNX
    Runtime rejects it for SentenceVectorsDL, NameFinderDL and
    DocumentCategorizerDL, and compare vectors on CUDA device 0 with the CPU
    session (skipped without a CUDA provider or device). The tiny model test
    fixtures are shared in TinyVectorModels.
    
    The opennlp-dl-gpu test classpath no longer has the CPU onnxruntime next
    to onnxruntime_gpu. Since onnxruntime_gpu has native libraries for
    linux-x64 and win-x64 only, the opennlp-dl-gpu tests are skipped on
    macOS and aarch64. The machine learning chapter documents the GPU
    setup and the supported platforms.
    
    Fixes #1389
    Fixes #1390
    
    Signed-off-by: Kristian Rickert <[email protected]>
    Co-authored-by: Kristian Rickert <[email protected]>
    Co-authored-by: Richard Zowalla <[email protected]>
    Co-authored-by: Claude Code 2.1.296 (Opus 5.5) <[email protected]>
---
 opennlp-core/opennlp-ml/opennlp-dl-gpu/pom.xml     |  34 ++++
 .../src/main/java/opennlp/dl/AbstractDL.java       |  22 ++-
 .../opennlp/dl/doccat/DocumentCategorizerDL.java   |  10 +-
 .../java/opennlp/dl/namefinder/NameFinderDL.java   |   5 +-
 .../java/opennlp/dl/vectors/SentenceVectorsDL.java |  47 ++++-
 .../opennlp/dl/AbstractDLColdStartCudaTest.java    | 139 +++++++++++++++
 .../src/test/java/opennlp/dl/GpuRequestTest.java   | 116 +++++++++++++
 .../src/test/java/opennlp/dl/LoadVocabTest.java    |  10 +-
 .../src/test/java/opennlp/dl/OnnxTestSupport.java  |  83 +++++++++
 .../dl/vectors/SentenceVectorsDLEmbedderTest.java  |  59 ++-----
 .../SentenceVectorsDLInferenceOptionsTest.java     | 189 +++++++++++++++++++++
 .../java/opennlp/dl/vectors/TinyVectorModels.java  | 112 ++++++++++++
 opennlp-docs/src/docbkx/machine-learning.xml       |  21 +++
 13 files changed, 784 insertions(+), 63 deletions(-)

diff --git a/opennlp-core/opennlp-ml/opennlp-dl-gpu/pom.xml 
b/opennlp-core/opennlp-ml/opennlp-dl-gpu/pom.xml
index cf98bb012..0eb2753a1 100644
--- a/opennlp-core/opennlp-ml/opennlp-dl-gpu/pom.xml
+++ b/opennlp-core/opennlp-ml/opennlp-dl-gpu/pom.xml
@@ -79,12 +79,19 @@
       <artifactId>slf4j-simple</artifactId>
       <scope>test</scope>
     </dependency>
+    <!-- Keep the CPU onnxruntime off the test classpath; this module uses 
onnxruntime_gpu. -->
     <dependency>
       <groupId>org.apache.opennlp</groupId>
       <artifactId>opennlp-dl</artifactId>
       <version>${project.version}</version>
       <type>test-jar</type>
       <scope>test</scope>
+      <exclusions>
+        <exclusion>
+          <groupId>com.microsoft.onnxruntime</groupId>
+          <artifactId>onnxruntime</artifactId>
+        </exclusion>
+      </exclusions>
     </dependency>
   </dependencies>
 
@@ -102,5 +109,32 @@
     </plugins>
   </build>
 
+  <!-- onnxruntime_gpu ships native libraries for linux-x64 and win-x64 only, 
unlike the
+       CPU onnxruntime jar, which also covers macOS and Linux aarch64. The 
tests cannot run there. -->
+  <profiles>
+    <profile>
+      <id>no-onnxruntime-gpu-on-mac</id>
+      <activation>
+        <os>
+          <family>mac</family>
+        </os>
+      </activation>
+      <properties>
+        <skipTests>true</skipTests>
+      </properties>
+    </profile>
+    <profile>
+      <id>no-onnxruntime-gpu-on-aarch64</id>
+      <activation>
+        <os>
+          <arch>aarch64</arch>
+        </os>
+      </activation>
+      <properties>
+        <skipTests>true</skipTests>
+      </properties>
+    </profile>
+  </profiles>
+
 
 </project>
\ No newline at end of file
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
index 9afe7b372..9ef3e35e2 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java
@@ -216,17 +216,31 @@ public abstract class AbstractDL implements AutoCloseable 
{
    * @param inferenceOptions The inference options to read the GPU 
configuration from.
    * @return The configured session options.
    *
-   * @throws OrtException Thrown if the CUDA execution provider cannot be 
added.
-   * @throws IllegalArgumentException Thrown if {@code inferenceOptions} is 
{@code null} or its
-   *     split sizes are invalid, see {@link #validateSplitOptions(int, int)}.
+   * @throws OrtException Thrown if the CUDA execution provider cannot be 
added: the ONNX Runtime
+   *     on the classpath has no CUDA support, its CUDA libraries cannot be 
loaded, or no device
+   *     answers to the configured device id. ONNX Runtime does not fall back 
to the CPU. The
+   *     session options created for the request are closed before the 
exception is thrown.
+   * @throws IllegalArgumentException Thrown if {@code inferenceOptions} is 
{@code null}, its
+   *     split sizes are invalid, see {@link #validateSplitOptions(int, int)}, 
or it requests a GPU
+   *     with a negative device id.
    */
   protected static OrtSession.SessionOptions sessionOptions(final 
InferenceOptions inferenceOptions)
       throws OrtException {
     ParamChecks.requireNonNullArg(inferenceOptions, "inferenceOptions");
     validateSplitOptions(inferenceOptions);
+    if (inferenceOptions.isGpu()) {
+      ParamChecks.requireNonNegative(inferenceOptions.getGpuDeviceId(), 
"gpuDeviceId");
+    }
     final OrtSession.SessionOptions sessionOptions = new 
OrtSession.SessionOptions();
     if (inferenceOptions.isGpu()) {
-      sessionOptions.addCUDA(inferenceOptions.getGpuDeviceId());
+      try {
+        // addCUDA needs ONNX Runtime's default logger, which exists once an 
environment does.
+        OrtEnvironment.getEnvironment();
+        sessionOptions.addCUDA(inferenceOptions.getGpuDeviceId());
+      } catch (OrtException | RuntimeException e) {
+        sessionOptions.close();
+        throw e;
+      }
     }
     return sessionOptions;
   }
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
index d2c1c2570..5010cce60 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/doccat/DocumentCategorizerDL.java
@@ -109,8 +109,9 @@ public class DocumentCategorizerDL extends AbstractDL 
implements DocumentCategor
    *     unsupported layout, or sets a lower casing that disagrees with {@code 
inferenceOptions}.
    * @throws IllegalArgumentException Thrown if {@code model}, {@code 
vocabulary},
    *     {@code categories}, {@code classificationScoringStrategy} or {@code 
inferenceOptions} is
-   *     {@code null}, if the split sizes of {@code inferenceOptions} are 
invalid, or if the
-   *     vocabulary lacks the classification, separator or unknown token.
+   *     {@code null}, if the split sizes of {@code inferenceOptions} are 
invalid, if it
+   *     requests a GPU with a negative device id, or if the vocabulary lacks 
the
+   *     classification, separator or unknown token.
    */
   public DocumentCategorizerDL(File model, File vocabulary, Map<Integer, 
String> categories,
                                ClassificationScoringStrategy 
classificationScoringStrategy,
@@ -148,8 +149,9 @@ public class DocumentCategorizerDL extends AbstractDL 
implements DocumentCategor
    *     keys to strings, or a key is not an integer.
    * @throws IllegalArgumentException Thrown if {@code model}, {@code 
vocabulary}, {@code config},
    *     {@code classificationScoringStrategy} or {@code inferenceOptions} is 
{@code null}, if the
-   *     split sizes of {@code inferenceOptions} are invalid, or if the 
vocabulary lacks the
-   *     classification, separator or unknown token.
+   *     split sizes of {@code inferenceOptions} are invalid, if it requests a 
GPU with a
+   *     negative device id, or if the vocabulary lacks the classification, 
separator or unknown
+   *     token.
    */
   public DocumentCategorizerDL(File model, File vocabulary, File config,
                                ClassificationScoringStrategy 
classificationScoringStrategy,
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/namefinder/NameFinderDL.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/namefinder/NameFinderDL.java
index 99358f549..afafe2e40 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/namefinder/NameFinderDL.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/namefinder/NameFinderDL.java
@@ -169,8 +169,9 @@ public class NameFinderDL extends AbstractDL implements 
OffsetMappingNameFinder
    *     the lower casing the component is configured with.
    * @throws IllegalArgumentException Thrown if {@code model}, {@code 
vocabulary},
    *     {@code ids2Labels}, {@code inferenceOptions} or {@code 
sentenceDetector} is
-   *     {@code null}, if the split sizes of {@code inferenceOptions} are 
invalid, or if the
-   *     vocabulary lacks the classification, separator or unknown token.
+   *     {@code null}, if the split sizes of {@code inferenceOptions} are 
invalid, if it
+   *     requests a GPU with a negative device id, or if the vocabulary lacks 
the
+   *     classification, separator or unknown token.
    */
   public NameFinderDL(File model, File vocabulary, Map<Integer, String> 
ids2Labels,
                       InferenceOptions inferenceOptions,
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java
index 8f6c080ff..08e114d83 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java
@@ -35,6 +35,7 @@ import ai.onnxruntime.OrtSession;
 import ai.onnxruntime.TensorInfo;
 
 import opennlp.dl.AbstractDL;
+import opennlp.dl.InferenceOptions;
 import opennlp.dl.Tokens;
 import opennlp.tools.commons.ThreadSafe;
 import opennlp.tools.embeddings.EmbeddingException;
@@ -156,12 +157,56 @@ public class SentenceVectorsDL extends AbstractDL 
implements TextEmbedder {
    *     {@code [batch, hidden]} or {@code [batch, tokens, hidden]}.
    * @throws OrtException Thrown if the {@code model} cannot be loaded.
    * @throws IOException Thrown if errors occurred loading the {@code model} 
or {@code vocabulary}.
+   * @throws opennlp.tools.util.InvalidFormatException Thrown if a JSON {@code 
vocabulary}
+   *     is malformed, has an unsupported layout, or sets a lower casing that 
disagrees with
+   *     the lower casing the component is configured with.
    */
   public SentenceVectorsDL(final File model, final File vocabulary, final 
boolean lowerCase,
       final Pooling pooling, final boolean normalize, final int maxLength)
       throws OrtException, IOException {
 
-    super(model, vocabulary, new OrtSession.SessionOptions(), lowerCase);
+    this(model, vocabulary, lowerCase, pooling, normalize, maxLength, new 
InferenceOptions());
+
+  }
+
+  /**
+   * Instantiates a {@link SentenceVectorsDL sentence vector generator} using 
ONNX models and the
+   * given {@link InferenceOptions}, so the session can run on a GPU through
+   * {@code opennlp-dl-gpu}.
+   *
+   * @param model The file name of a sentence vectors ONNX model.
+   * @param vocabulary The file name of the vocabulary file for the model.
+   * @param lowerCase {@code true} for uncased models (lower casing and accent
+   *     stripping during tokenization), {@code false} for cased models. A 
lower case setting in
+   *     {@code inferenceOptions} takes precedence.
+   * @param pooling How token vectors are pooled. Not used for a model with a
+   *     {@code sentence_embedding} output. Must not be {@code null}.
+   * @param normalize {@code true} to scale every vector to unit length.
+   * @param maxLength The maximum number of tokens per input, {@code [CLS]} 
and {@code [SEP]}
+   *     included; longer input is truncated. Must be at least {@code 2}.
+   * @param inferenceOptions The {@link InferenceOptions}, of which the GPU 
settings and the lower
+   *     case setting are used. Must not be {@code null}.
+   *
+   * @throws IllegalArgumentException Thrown if {@code pooling} or {@code 
inferenceOptions} is
+   *     {@code null}, if {@code inferenceOptions} holds invalid split options 
or requests a GPU
+   *     with a negative device id, if {@code maxLength} is less than {@code 
2}, or if the model
+   *     has no output of shape {@code [batch, hidden]} or {@code [batch, 
tokens, hidden]}.
+   * @throws OrtException Thrown if the {@code model} cannot be loaded, or if 
a GPU was requested
+   *     and ONNX Runtime cannot provide it. ONNX Runtime does not fall back 
to the CPU.
+   * @throws IOException Thrown if errors occurred loading the {@code model} 
or {@code vocabulary}.
+   * @throws opennlp.tools.util.InvalidFormatException Thrown if a JSON {@code 
vocabulary}
+   *     is malformed, has an unsupported layout, or sets a lower casing that 
disagrees with
+   *     the lower casing the component is configured with.
+   *
+   * @since 3.0.0
+   */
+  public SentenceVectorsDL(final File model, final File vocabulary, final 
boolean lowerCase,
+      final Pooling pooling, final boolean normalize, final int maxLength,
+      final InferenceOptions inferenceOptions)
+      throws OrtException, IOException {
+
+    super(model, vocabulary, sessionOptions(inferenceOptions),
+        resolveLowerCase(inferenceOptions, lowerCase));
     try {
       ParamChecks.requireNonNullArg(pooling, "pooling");
       if (maxLength < MIN_LENGTH) {
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/AbstractDLColdStartCudaTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/AbstractDLColdStartCudaTest.java
new file mode 100644
index 000000000..6a23c2d83
--- /dev/null
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/AbstractDLColdStartCudaTest.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+package opennlp.dl;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import ai.onnxruntime.OrtException;
+import ai.onnxruntime.OrtSession;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static opennlp.dl.OnnxTestSupport.gpu;
+import static opennlp.dl.OnnxTestSupport.hasCudaProvider;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Tests that {@link AbstractDL#sessionOptions(InferenceOptions)} can request 
the CUDA execution
+ * provider as the first ONNX Runtime call of a JVM. Without a usable CUDA 
installation the
+ * request may be rejected, but never because ONNX Runtime's default logger is 
missing.
+ *
+ * <p>Whether ONNX Runtime has been used is process-wide state that the other 
tests of the module
+ * change, so the request runs in a child JVM through {@link 
FirstCudaRequest}. The test runs
+ * where ONNX Runtime has the CUDA provider, which is {@code opennlp-dl-gpu}; 
a CUDA device is not
+ * needed.</p>
+ */
+class AbstractDLColdStartCudaTest {
+
+  private static final String ADDED = "ADDED";
+
+  private static final String REJECTED = "REJECTED ";
+
+  private static final String DEFAULT_LOGGER_FAILURE = "DefaultLogger";
+
+  private static final long TIMEOUT_MINUTES = 2;
+
+  /**
+   * Runs {@link FirstCudaRequest} in a child JVM and checks that its outcome 
line reports the
+   * provider as added, or a rejection that is not about the default logger.
+   */
+  @Test
+  void testFirstCudaRequestOfAJvm(@TempDir final Path dir)
+      throws IOException, InterruptedException {
+    assumeTrue(hasCudaProvider(), "the ONNX Runtime on the classpath has no 
CUDA execution provider");
+    final Path out = dir.resolve("stdout.log");
+    final Path err = dir.resolve("stderr.log");
+    final Path java = Path.of(System.getProperty("java.home"), "bin", "java");
+    final Process process = new ProcessBuilder(List.of(java.toString(), "-cp",
+        System.getProperty("java.class.path"), 
FirstCudaRequest.class.getName()))
+        .redirectOutput(out.toFile())
+        .redirectError(err.toFile())
+        .start();
+    if (!process.waitFor(TIMEOUT_MINUTES, TimeUnit.MINUTES)) {
+      process.destroyForcibly();
+      fail("the child JVM did not finish within " + TIMEOUT_MINUTES + " 
minutes");
+    }
+    // The outcome line is read from stdout only: ONNX Runtime logs to stderr, 
on Windows in UTF-16.
+    final String stdout = read(out);
+    final String report = "stdout:\n" + stdout + "\nstderr:\n" + read(err);
+    assertEquals(0, process.exitValue(), report);
+    final String outcome = outcomeLine(stdout, report);
+    assertTrue(outcome.equals(ADDED) || 
!outcome.contains(DEFAULT_LOGGER_FAILURE), report);
+  }
+
+  /**
+   * Reads a file of child JVM output, replacing bytes that are not UTF-8.
+   *
+   * @param file The file to read.
+   * @return The content of the file.
+   * @throws IOException Thrown if the file cannot be read.
+   */
+  private static String read(final Path file) throws IOException {
+    return new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
+  }
+
+  /**
+   * Finds the outcome line that {@link FirstCudaRequest} printed.
+   *
+   * @param stdout The standard output of the child JVM.
+   * @param report The complete child JVM output, for the failure message.
+   * @return The line that is {@link #ADDED} or starts with {@link #REJECTED}.
+   */
+  private static String outcomeLine(final String stdout, final String report) {
+    return stdout.lines()
+        .filter(line -> line.equals(ADDED) || line.startsWith(REJECTED))
+        .findFirst()
+        .orElseGet(() -> fail("no outcome line in the child JVM output:\n" + 
report));
+  }
+
+  /**
+   * The child JVM entry point: requests the CUDA execution provider for 
device {@code 0} as the
+   * first ONNX Runtime call, then prints {@link #ADDED} or {@link #REJECTED} 
followed by the
+   * ONNX Runtime error on one line.
+   */
+  public static final class FirstCudaRequest {
+
+    private FirstCudaRequest() {
+    }
+
+    /**
+     * Entry point of the child JVM that the cold start test starts, because 
the CUDA request has
+     * to be the first ONNX Runtime call of a fresh JVM.
+     *
+     * @param args Ignored; the test starts the child JVM without arguments.
+     */
+    public static void main(final String[] args) {
+      try {
+        final OrtSession.SessionOptions sessionOptions = 
AbstractDL.sessionOptions(gpu(0));
+        sessionOptions.close();
+        System.out.println(ADDED);
+      } catch (OrtException e) {
+        System.out.println(REJECTED + e.getCode() + ": "
+            + e.getMessage().replace('\n', ' ').replace('\r', ' '));
+      }
+    }
+  }
+}
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/GpuRequestTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/GpuRequestTest.java
new file mode 100644
index 000000000..833b6b592
--- /dev/null
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/GpuRequestTest.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+package opennlp.dl;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+
+import ai.onnxruntime.OrtException;
+import ai.onnxruntime.OrtSession;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.dl.doccat.DocumentCategorizerDL;
+import opennlp.dl.doccat.scoring.AverageClassificationScoringStrategy;
+import opennlp.dl.namefinder.NameFinderDL;
+import opennlp.tools.sentdetect.NewlineSentenceDetector;
+
+import static opennlp.dl.OnnxTestSupport.UNUSABLE_DEVICE_ID;
+import static opennlp.dl.OnnxTestSupport.copy;
+import static opennlp.dl.OnnxTestSupport.gpu;
+import static opennlp.dl.vectors.TinyVectorModels.TINY_VECTORS;
+import static opennlp.dl.vectors.TinyVectorModels.model;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests how a GPU request in {@link InferenceOptions} reaches ONNX Runtime: a 
request for a
+ * device that is not usable is rejected with an {@link OrtException}, never 
served by the CPU,
+ * and a CPU request does not ask for any device.
+ *
+ * <p>In {@code opennlp-dl}, whose ONNX Runtime has no CUDA support, every GPU 
request is
+ * rejected, so the tests there check that a request never falls back to the 
CPU. The rejection
+ * of the device id itself is checked when {@code opennlp-dl-gpu} runs these 
tests.</p>
+ */
+class GpuRequestTest {
+
+  private static final String PLAIN_VOCAB = "/opennlp/dl/vocab-plain.txt";
+  private static final String VOCAB_FILE = "vocab.txt";
+
+  /** The device id is not read while {@code gpu} is off, so no provider is 
requested. */
+  @ParameterizedTest
+  @ValueSource(ints = {-1, 0, UNUSABLE_DEVICE_ID})
+  void testSessionOptionsWithoutGpuIgnoreTheDeviceId(final int deviceId) {
+    final InferenceOptions options = new InferenceOptions();
+    options.setGpuDeviceId(deviceId);
+    assertDoesNotThrow(() -> {
+      try (OrtSession.SessionOptions sessionOptions = 
AbstractDL.sessionOptions(options)) {
+        assertNotNull(sessionOptions);
+      }
+    });
+  }
+
+  /** A name finder on a device id that no machine has fails instead of 
running on the CPU. */
+  @Test
+  void testNameFinderRejectsAnUnusableDevice(@TempDir final Path dir) throws 
IOException {
+    final File model = model(dir, TINY_VECTORS);
+    final File vocab = copy(dir, PLAIN_VOCAB, VOCAB_FILE);
+    final InferenceOptions options = gpu(UNUSABLE_DEVICE_ID);
+    final Map<Integer, String> labels = Map.of(0, "O", 1, "B-PER");
+    assertThrows(OrtException.class, () -> new NameFinderDL(model, vocab, 
labels, options,
+        new NewlineSentenceDetector()).close());
+  }
+
+  /** A categorizer on a device id that no machine has fails instead of 
running on the CPU. */
+  @Test
+  void testDocumentCategorizerRejectsAnUnusableDevice(@TempDir final Path dir)
+      throws IOException {
+    final File model = model(dir, TINY_VECTORS);
+    final File vocab = copy(dir, PLAIN_VOCAB, VOCAB_FILE);
+    final InferenceOptions options = gpu(UNUSABLE_DEVICE_ID);
+    final Map<Integer, String> categories = Map.of(0, "a", 1, "b");
+    assertThrows(OrtException.class, () -> new DocumentCategorizerDL(model, 
vocab, categories,
+        new AverageClassificationScoringStrategy(), options).close());
+  }
+
+  /** Null options are rejected before a device is requested. */
+  @Test
+  void testSessionOptionsRejectNullOptions() {
+    assertThrows(IllegalArgumentException.class, () -> 
AbstractDL.sessionOptions(null));
+  }
+
+  /**
+   * Invalid split sizes are rejected with an {@link IllegalArgumentException} 
before a device is
+   * requested, so the unusable device id never reaches ONNX Runtime.
+   */
+  @ParameterizedTest
+  @CsvSource({"0,0", "-1,0", "10,-1", "10,10", "10,11"})
+  void testSessionOptionsRejectInvalidSplitBeforeAnyDevice(final int splitSize,
+      final int overlap) {
+    final InferenceOptions options = gpu(UNUSABLE_DEVICE_ID);
+    options.setDocumentSplitSize(splitSize);
+    options.setSplitOverlapSize(overlap);
+    assertThrows(IllegalArgumentException.class, () -> 
AbstractDL.sessionOptions(options));
+  }
+}
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/LoadVocabTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/LoadVocabTest.java
index 4428469ae..8ee1c9711 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/LoadVocabTest.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/LoadVocabTest.java
@@ -19,13 +19,10 @@ package opennlp.dl;
 
 import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.stream.Stream;
 
 import org.junit.jupiter.api.Test;
@@ -83,12 +80,7 @@ public class LoadVocabTest {
   private Path tempDir;
 
   private File getResource(String name) throws IOException {
-    try (InputStream is = Objects.requireNonNull(
-        getClass().getResourceAsStream("/opennlp/dl/" + name))) {
-      final Path file = tempDir.resolve(name);
-      Files.copy(is, file, StandardCopyOption.REPLACE_EXISTING);
-      return file.toFile();
-    }
+    return OnnxTestSupport.copy(tempDir, "/opennlp/dl/" + name, name);
   }
 
   /**
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/OnnxTestSupport.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/OnnxTestSupport.java
new file mode 100644
index 000000000..ebdcbb80a
--- /dev/null
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/OnnxTestSupport.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+package opennlp.dl;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.Objects;
+
+import ai.onnxruntime.OrtEnvironment;
+import ai.onnxruntime.OrtProvider;
+
+/**
+ * Test helpers for the ONNX components of this module: test resources as 
files, and the GPU
+ * settings of {@link InferenceOptions}.
+ */
+public final class OnnxTestSupport {
+
+  /** A CUDA device id that no machine has. */
+  public static final int UNUSABLE_DEVICE_ID = 99;
+
+  private OnnxTestSupport() {
+  }
+
+  /**
+   * Copies a classpath resource into a directory. The resource is copied 
rather than resolved in
+   * place, because inside the {@code opennlp-dl} test-jar, as used by {@code 
opennlp-dl-gpu}, it
+   * is not a file.
+   *
+   * @param dir The directory to copy into.
+   * @param resource The absolute classpath name of the resource.
+   * @param name The file name of the copy.
+   * @return The copied file.
+   * @throws IOException Thrown if the resource cannot be copied.
+   */
+  public static File copy(final Path dir, final String resource, final String 
name)
+      throws IOException {
+    final Path file = dir.resolve(name);
+    try (InputStream is = Objects.requireNonNull(
+        OnnxTestSupport.class.getResourceAsStream(resource), resource)) {
+      Files.copy(is, file, StandardCopyOption.REPLACE_EXISTING);
+    }
+    return file.toFile();
+  }
+
+  /**
+   * {@return inference options that request the GPU with the given device id}
+   *
+   * @param deviceId The CUDA device id.
+   */
+  public static InferenceOptions gpu(final int deviceId) {
+    final InferenceOptions options = new InferenceOptions();
+    options.setGpu(true);
+    options.setGpuDeviceId(deviceId);
+    return options;
+  }
+
+  /**
+   * {@return whether the ONNX Runtime on the classpath is built with the CUDA 
execution provider}
+   * This says nothing about a usable CUDA device.
+   */
+  public static boolean hasCudaProvider() {
+    return OrtEnvironment.getAvailableProviders().contains(OrtProvider.CUDA);
+  }
+}
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java
index 8351f8930..01d2fe470 100644
--- 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLEmbedderTest.java
@@ -19,14 +19,11 @@ package opennlp.dl.vectors;
 
 import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
@@ -42,6 +39,12 @@ import static 
opennlp.dl.vectors.OnnxTextEmbedderProvider.MAX_LENGTH_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.NORMALIZE_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.POOLING_OPTION;
 import static opennlp.dl.vectors.OnnxTextEmbedderProvider.VOCABULARY_OPTION;
+import static opennlp.dl.vectors.TinyVectorModels.DELTA;
+import static opennlp.dl.vectors.TinyVectorModels.TINY_POOLED;
+import static opennlp.dl.vectors.TinyVectorModels.TINY_VECTORS;
+import static opennlp.dl.vectors.TinyVectorModels.model;
+import static opennlp.dl.vectors.TinyVectorModels.scale;
+import static opennlp.dl.vectors.TinyVectorModels.vocab;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
@@ -60,8 +63,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
  */
 class SentenceVectorsDLEmbedderTest {
 
-  private static final float DELTA = 1e-5f;
-
   // W / |W|, the unit-length vector of every input of the tiny models.
   private static final float[] UNIT_VECTOR = scale(1 / (float) 
Math.sqrt(5.25));
 
@@ -74,34 +75,6 @@ class SentenceVectorsDLEmbedderTest {
 
   private static final float[] CLS_VECTOR = scale(7);
 
-  private static float[] scale(final float factor) {
-    return new float[] {0.5f * factor, -1f * factor, 2f * factor};
-  }
-
-  // Copy the model out of the classpath rather than resolving it in place: 
when this test runs
-  // from the opennlp-dl test-jar (as it does in opennlp-dl-gpu) the resource 
URI is inside a jar
-  // and is not hierarchical, so new File(uri) would fail.
-  private static File model(final Path dir, final String name) throws 
IOException {
-    final Path file = dir.resolve(name);
-    try (InputStream is = 
Objects.requireNonNull(SentenceVectorsDLEmbedderTest.class
-        .getResourceAsStream("/opennlp/dl/vectors/" + name))) {
-      Files.copy(is, file, StandardCopyOption.REPLACE_EXISTING);
-    }
-    return file.toFile();
-  }
-
-  private static File model(final Path dir) throws IOException {
-    return model(dir, "tiny-vectors.onnx");
-  }
-
-  private static File vocab(final Path dir) throws IOException {
-    final Path file = dir.resolve("vocab.txt");
-    // Line number = id: [UNK]=2, [SEP]=3, hello=4, world=5, [CLS]=7.
-    Files.write(file, List.of("[PAD]", "unused1", "[UNK]", "[SEP]", "hello", 
"world",
-        "unused2", "[CLS]"));
-    return file.toFile();
-  }
-
   // The vocabulary above as a tokenizer.json of an uncased WordPiece model.
   private static File tokenizerJson(final Path dir, final boolean lowercase) 
throws IOException {
     final Path file = dir.resolve("tokenizer.json");
@@ -114,12 +87,12 @@ class SentenceVectorsDLEmbedderTest {
 
   private static SentenceVectorsDL meanVectors(final Path dir, final int 
maxLength)
       throws Exception {
-    return new SentenceVectorsDL(model(dir), vocab(dir), true, Pooling.MEAN, 
false, maxLength);
+    return new SentenceVectorsDL(model(dir, TINY_VECTORS), vocab(dir), true, 
Pooling.MEAN, false, maxLength);
   }
 
   @Test
   void testSelectedProviderPreservesInference(@TempDir final Path dir) throws 
Exception {
-    final Path graph = model(dir).toPath();
+    final Path graph = model(dir, TINY_VECTORS).toPath();
     vocab(dir);
     final Map<String, String> options = Map.of(VOCABULARY_OPTION, "vocab.txt");
     final ProviderSpec spec = ProviderSpec.of(graph, options);
@@ -157,7 +130,7 @@ class SentenceVectorsDLEmbedderTest {
 
   @Test
   void testEmbedderContractOverARealSession(@TempDir final Path dir) throws 
Exception {
-    try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir), 
vocab(dir))) {
+    try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir, 
TINY_VECTORS), vocab(dir))) {
 
       // The primary entry point, against which the adapter below is compared.
       assertArrayEquals(UNIT_VECTOR, vectors.getVectors("hello world"), DELTA);
@@ -192,7 +165,7 @@ class SentenceVectorsDLEmbedderTest {
   @Test
   void testPooling(@TempDir final Path dir) throws Exception {
     try (SentenceVectorsDL mean = meanVectors(dir, 
SentenceVectorsDL.DEFAULT_MAX_LENGTH);
-         SentenceVectorsDL cls = new SentenceVectorsDL(model(dir), vocab(dir), 
true,
+         SentenceVectorsDL cls = new SentenceVectorsDL(model(dir, 
TINY_VECTORS), vocab(dir), true,
              Pooling.CLS, false, SentenceVectorsDL.DEFAULT_MAX_LENGTH)) {
       assertArrayEquals(HELLO_WORLD_MEAN, mean.embed("hello world"), DELTA);
       assertArrayEquals(HELLO_MEAN, mean.embed("hello"), DELTA);
@@ -213,26 +186,26 @@ class SentenceVectorsDLEmbedderTest {
       assertArrayEquals(three.embed("hello"), three.embedAll(List.of("hello 
world"))[0]);
     }
     assertThrows(IllegalArgumentException.class, () -> meanVectors(dir, 1));
-    assertThrows(IllegalArgumentException.class, () -> new 
SentenceVectorsDL(model(dir),
+    assertThrows(IllegalArgumentException.class, () -> new 
SentenceVectorsDL(model(dir, TINY_VECTORS),
         vocab(dir), true, null, false, SentenceVectorsDL.DEFAULT_MAX_LENGTH));
   }
 
   @Test
   void testTokenizerJsonVocabularyMustAgreeWithLowerCase(@TempDir final Path 
dir)
       throws Exception {
-    try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir), 
tokenizerJson(dir, true),
+    try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir, 
TINY_VECTORS), tokenizerJson(dir, true),
              true, Pooling.MEAN, false, SentenceVectorsDL.DEFAULT_MAX_LENGTH)) 
{
       assertArrayEquals(HELLO_WORLD_MEAN, vectors.embed("Hello World"), DELTA);
     }
     final InvalidFormatException e = assertThrows(InvalidFormatException.class,
-        () -> new SentenceVectorsDL(model(dir), tokenizerJson(dir, false), 
true));
+        () -> new SentenceVectorsDL(model(dir, TINY_VECTORS), 
tokenizerJson(dir, false), true));
     assertTrue(e.getMessage().contains("tokenizer.json"), e.getMessage());
     assertTrue(e.getMessage().contains("normalizer.lowercase"), 
e.getMessage());
   }
 
   @Test
   void testPooledOutputIsSelectedByName(@TempDir final Path dir) throws 
Exception {
-    final File pooledModel = model(dir, "tiny-pooled.onnx");
+    final File pooledModel = model(dir, TINY_POOLED);
     try (SentenceVectorsDL raw = new SentenceVectorsDL(pooledModel, 
vocab(dir), true,
              Pooling.CLS, false, SentenceVectorsDL.DEFAULT_MAX_LENGTH);
          SentenceVectorsDL unit = new SentenceVectorsDL(pooledModel, 
vocab(dir))) {
@@ -247,7 +220,7 @@ class SentenceVectorsDLEmbedderTest {
 
   @Test
   void testEmbedAfterCloseThrows(@TempDir final Path dir) throws Exception {
-    final SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir), 
vocab(dir));
+    final SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir, 
TINY_VECTORS), vocab(dir));
     vectors.close();
     vectors.close();
     assertThrows(IllegalStateException.class, () -> vectors.embed("hello"));
@@ -280,7 +253,7 @@ class SentenceVectorsDLEmbedderTest {
    */
   @Test
   void testEmbedAllEdges(@TempDir final Path dir) throws Exception {
-    try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir), 
vocab(dir))) {
+    try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir, 
TINY_VECTORS), vocab(dir))) {
       assertEquals(0, vectors.embedAll(List.of()).length);
       assertEquals("texts[1] must not be null", 
assertThrows(IllegalArgumentException.class,
           () -> vectors.embedAll(Arrays.asList("hello", null))).getMessage());
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLInferenceOptionsTest.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLInferenceOptionsTest.java
new file mode 100644
index 000000000..da8cb3a70
--- /dev/null
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/SentenceVectorsDLInferenceOptionsTest.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package opennlp.dl.vectors;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+
+import ai.onnxruntime.OrtException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.dl.InferenceOptions;
+
+import static opennlp.dl.OnnxTestSupport.UNUSABLE_DEVICE_ID;
+import static opennlp.dl.OnnxTestSupport.gpu;
+import static opennlp.dl.vectors.TinyVectorModels.DELTA;
+import static opennlp.dl.vectors.TinyVectorModels.TINY_POOLED;
+import static opennlp.dl.vectors.TinyVectorModels.TINY_VECTORS;
+import static opennlp.dl.vectors.TinyVectorModels.gpuOrAbort;
+import static opennlp.dl.vectors.TinyVectorModels.model;
+import static opennlp.dl.vectors.TinyVectorModels.scale;
+import static opennlp.dl.vectors.TinyVectorModels.vocab;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests the {@link SentenceVectorsDL} constructor that takes {@link 
InferenceOptions}.
+ */
+class SentenceVectorsDLInferenceOptionsTest {
+
+  private static final String HELLO = "hello";
+
+  private static final String TEXT = "hello world";
+
+  private static final String LONGER_TEXT = "world hello world";
+
+  /**
+   * {@return the model, pooling and unit length combinations, one per path 
after the inference}
+   */
+  static Stream<Arguments> gpuParityCases() {
+    return Stream.of(
+        Arguments.of(TINY_VECTORS, Pooling.MEAN, false),
+        Arguments.of(TINY_VECTORS, Pooling.MEAN, true),
+        Arguments.of(TINY_VECTORS, Pooling.CLS, false),
+        Arguments.of(TINY_VECTORS, Pooling.CLS, true),
+        Arguments.of(TINY_POOLED, Pooling.CLS, false),
+        Arguments.of(TINY_POOLED, Pooling.MEAN, true));
+  }
+
+  /**
+   * Checks that every path gives the same vectors on CUDA device {@code 0} as 
on the CPU, where
+   * a CUDA device exists; skipped elsewhere.
+   */
+  @ParameterizedTest
+  @MethodSource("gpuParityCases")
+  void testGpuVectorsMatchTheCpu(final String modelName, final Pooling pooling,
+      final boolean normalize, @TempDir final Path dir) throws Exception {
+    final File model = model(dir, modelName);
+    final File vocab = vocab(dir);
+    try (SentenceVectorsDL gpu = gpuOrAbort(model, vocab, pooling, normalize);
+         SentenceVectorsDL cpu = new SentenceVectorsDL(model, vocab, true, 
pooling, normalize,
+             SentenceVectorsDL.DEFAULT_MAX_LENGTH, new InferenceOptions())) {
+      assertEquals(cpu.dimension(), gpu.dimension());
+      assertArrayEquals(cpu.embed(TEXT), gpu.embed(TEXT), DELTA);
+      final List<String> texts = List.of(TEXT, LONGER_TEXT, "");
+      final float[][] cpuBatch = cpu.embedAll(texts);
+      final float[][] gpuBatch = gpu.embedAll(texts);
+      for (int i = 0; i < cpuBatch.length; i++) {
+        assertArrayEquals(cpuBatch[i], gpuBatch[i], DELTA, "row " + i);
+      }
+    }
+  }
+
+  /**
+   * Checks that a GPU request on a negative device id is rejected before ONNX 
Runtime is asked
+   * for a device.
+   */
+  @ParameterizedTest
+  @ValueSource(ints = {-1, Integer.MIN_VALUE})
+  void testNegativeDeviceIdsAreRejected(final int deviceId, @TempDir final 
Path dir)
+      throws IOException {
+    final File model = model(dir, TINY_VECTORS);
+    final File vocab = vocab(dir);
+    final InferenceOptions options = gpu(deviceId);
+    final IllegalArgumentException e = 
assertThrows(IllegalArgumentException.class,
+        () -> new SentenceVectorsDL(model, vocab, true, Pooling.MEAN, false,
+            SentenceVectorsDL.DEFAULT_MAX_LENGTH, options).close());
+    assertEquals("gpuDeviceId must not be negative", e.getMessage());
+  }
+
+  /**
+   * Checks that a GPU request on a device id that no machine has is rejected 
by ONNX Runtime
+   * instead of running on the CPU.
+   */
+  @ParameterizedTest
+  @ValueSource(ints = {UNUSABLE_DEVICE_ID, Integer.MAX_VALUE})
+  void testUnusableDeviceIdsAreRejected(final int deviceId, @TempDir final 
Path dir)
+      throws IOException {
+    final File model = model(dir, TINY_VECTORS);
+    final File vocab = vocab(dir);
+    final InferenceOptions options = gpu(deviceId);
+    assertThrows(OrtException.class, () -> new SentenceVectorsDL(model, vocab, 
true, Pooling.MEAN,
+        false, SentenceVectorsDL.DEFAULT_MAX_LENGTH, options).close());
+  }
+
+  /**
+   * {@return the lower case in the options ({@code null} for unset), the 
argument, and the
+   * expected lower casing}
+   */
+  static Stream<Arguments> lowerCaseCases() {
+    return Stream.of(
+        Arguments.of(Boolean.TRUE, true, true),
+        Arguments.of(Boolean.TRUE, false, true),
+        Arguments.of(Boolean.FALSE, true, false),
+        Arguments.of(Boolean.FALSE, false, false),
+        Arguments.of(null, true, true),
+        Arguments.of(null, false, false));
+  }
+
+  /**
+   * Checks that the lower case setting is {@code options.getLowerCase()} when 
it is set and the
+   * {@code lowerCase} argument when it is not. "HELLO" encodes as {@code 
[UNK]} (id 2) unless
+   * lower cased to {@code hello} (id 4), so the mean of the ids is {@code 4} 
or {@code 14 / 3}.
+   */
+  @ParameterizedTest
+  @MethodSource("lowerCaseCases")
+  void testLowerCasePrecedence(final Boolean inOptions, final boolean argument,
+      final boolean expectedLowerCase, @TempDir final Path dir) throws 
Exception {
+    final InferenceOptions options = new InferenceOptions();
+    if (inOptions != null) {
+      options.setLowerCase(inOptions);
+    }
+    try (SentenceVectorsDL vectors = new SentenceVectorsDL(model(dir, 
TINY_VECTORS), vocab(dir),
+        argument, Pooling.MEAN, false, SentenceVectorsDL.DEFAULT_MAX_LENGTH, 
options)) {
+      assertArrayEquals(scale(expectedLowerCase ? 14 / 3f : 4f), 
vectors.embed("HELLO"), DELTA);
+    }
+  }
+
+  /**
+   * Checks that default {@link InferenceOptions} give the same vectors as the 
constructor
+   * without options.
+   */
+  @Test
+  void testDefaultOptionsMatchTheDefaultSession(@TempDir final Path dir) 
throws Exception {
+    final File model = model(dir, TINY_VECTORS);
+    final File vocab = vocab(dir);
+    try (SentenceVectorsDL plain = new SentenceVectorsDL(model, vocab, true, 
Pooling.MEAN, false,
+        SentenceVectorsDL.DEFAULT_MAX_LENGTH);
+         SentenceVectorsDL withOptions = new SentenceVectorsDL(model, vocab, 
true, Pooling.MEAN,
+             false, SentenceVectorsDL.DEFAULT_MAX_LENGTH, new 
InferenceOptions())) {
+      assertArrayEquals(plain.embed(TEXT), withOptions.embed(TEXT), DELTA);
+    }
+  }
+
+  /**
+   * Checks that {@code null} options are rejected with the parameter name.
+   */
+  @Test
+  void testNullOptionsAreRejected(@TempDir final Path dir) throws IOException {
+    final File model = model(dir, TINY_VECTORS);
+    final File vocab = vocab(dir);
+    final IllegalArgumentException e = 
assertThrows(IllegalArgumentException.class,
+        () -> new SentenceVectorsDL(model, vocab, true, Pooling.MEAN, false,
+            SentenceVectorsDL.DEFAULT_MAX_LENGTH, null).close());
+    assertEquals("inferenceOptions must not be null", e.getMessage());
+  }
+}
diff --git 
a/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java
 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java
new file mode 100644
index 000000000..d48d4ae16
--- /dev/null
+++ 
b/opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/vectors/TinyVectorModels.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package opennlp.dl.vectors;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import ai.onnxruntime.OrtException;
+
+import static opennlp.dl.OnnxTestSupport.copy;
+import static opennlp.dl.OnnxTestSupport.gpu;
+import static opennlp.dl.OnnxTestSupport.hasCudaProvider;
+import static org.junit.jupiter.api.Assumptions.abort;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Test fixtures for the bundled tiny ONNX models. {@code tiny-vectors.onnx} 
(see
+ * {@code gen_tiny_vectors_model.py}) computes {@code output[b][t] = 
float(input_ids[b][t]) * W}
+ * with {@code W = [0.5, -1, 2]}; {@code tiny-pooled.onnx} (see {@code 
gen_tiny_pooled_model.py})
+ * adds a {@code sentence_embedding} output holding the sum of the ids times 
{@code W}.
+ */
+public final class TinyVectorModels {
+
+  /** The model with a token output only. */
+  public static final String TINY_VECTORS = "tiny-vectors.onnx";
+
+  /** The model with an additional {@code sentence_embedding} output. */
+  public static final String TINY_POOLED = "tiny-pooled.onnx";
+
+  /** The tolerance for comparing vectors of the tiny models. */
+  public static final float DELTA = 1e-5f;
+
+  private static final String VECTORS_RESOURCES = "/opennlp/dl/vectors/";
+
+  private TinyVectorModels() {
+  }
+
+  /**
+   * Copies a tiny model into a directory.
+   *
+   * @param dir The directory to copy into.
+   * @param name {@link #TINY_VECTORS} or {@link #TINY_POOLED}.
+   * @return The model file.
+   * @throws IOException Thrown if the model cannot be copied.
+   */
+  public static File model(final Path dir, final String name) throws 
IOException {
+    return copy(dir, VECTORS_RESOURCES + name, name);
+  }
+
+  /**
+   * Writes the vocabulary of the tiny models, where the line number is the id:
+   * {@code [UNK]=2, [SEP]=3, hello=4, world=5, [CLS]=7}.
+   *
+   * @param dir The directory to write into.
+   * @return The vocabulary file.
+   * @throws IOException Thrown if the vocabulary cannot be written.
+   */
+  public static File vocab(final Path dir) throws IOException {
+    final Path file = dir.resolve("vocab.txt");
+    Files.write(file, List.of("[PAD]", "unused1", "[UNK]", "[SEP]", "hello", 
"world",
+        "unused2", "[CLS]"));
+    return file.toFile();
+  }
+
+  /**
+   * {@return {@code W} times {@code factor}, the expected vector for a mean 
or summed id}
+   *
+   * @param factor The mean or summed token id.
+   */
+  public static float[] scale(final float factor) {
+    return new float[] {0.5f * factor, -1f * factor, 2f * factor};
+  }
+
+  /**
+   * Opens a session on CUDA device {@code 0}, or aborts the calling test 
where the ONNX Runtime
+   * on the classpath has no CUDA execution provider or the device is not 
usable.
+   *
+   * @param model The model file.
+   * @param vocab The vocabulary file.
+   * @param pooling The pooling.
+   * @param normalize {@code true} to scale every vector to unit length.
+   * @return The session on the GPU.
+   * @throws IOException Thrown if the model or vocabulary cannot be read.
+   */
+  public static SentenceVectorsDL gpuOrAbort(final File model, final File 
vocab,
+      final Pooling pooling, final boolean normalize) throws IOException {
+    assumeTrue(hasCudaProvider(), "the ONNX Runtime on the classpath has no 
CUDA execution provider");
+    try {
+      return new SentenceVectorsDL(model, vocab, true, pooling, normalize,
+          SentenceVectorsDL.DEFAULT_MAX_LENGTH, gpu(0));
+    } catch (OrtException e) {
+      return abort("no usable CUDA device 0: " + e.getMessage());
+    }
+  }
+}
diff --git a/opennlp-docs/src/docbkx/machine-learning.xml 
b/opennlp-docs/src/docbkx/machine-learning.xml
index a1d5a8660..69c3f72d3 100644
--- a/opennlp-docs/src/docbkx/machine-learning.xml
+++ b/opennlp-docs/src/docbkx/machine-learning.xml
@@ -183,5 +183,26 @@ try (TextEmbedder embedder = 
providers.select(spec).create(spec)) {
                        has no provider until another one is installed. 
Existing <code>SentenceVectorsDL</code>
                        constructors remain available.
                </para>
+               <para>
+                       To compute the vectors on a GPU, depend on 
<code>opennlp-dl-gpu</code> in place of
+                       <code>opennlp-dl</code> and pass 
<code>InferenceOptions</code> to
+                       <code>SentenceVectorsDL</code>. 
<code>opennlp-dl-gpu</code> supports NVIDIA GPUs on
+                       linux-x64 and win-x64 only. On macOS, use 
<code>opennlp-dl</code>. The machine needs
+                       the CUDA and cuDNN versions that the ONNX Runtime 
release requires, see the
+                       <ulink 
url="https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html#requirements";>CUDA
+                       execution provider requirements</ulink>. The 
<code>onnx</code> provider spec has no device
+                       option, so an embedder created through it runs on the 
CPU. If CUDA or the configured
+                       device is not available, the constructor throws an 
<code>OrtException</code>; ONNX Runtime
+                       does not fall back to the CPU. A negative device id is 
rejected with an
+                       <code>IllegalArgumentException</code>. A lower case 
setting in the options takes
+                       precedence over the <code>lowerCase</code> argument.
+               </para>
+               <programlisting language="java"><![CDATA[InferenceOptions 
options = new InferenceOptions();
+options.setGpu(true);
+options.setGpuDeviceId(0);
+try (SentenceVectorsDL vectors = new SentenceVectorsDL(model, vocab, true, 
Pooling.MEAN, true,
+    SentenceVectorsDL.DEFAULT_MAX_LENGTH, options)) {
+  float[] vector = vectors.embed("first sentence");
+}]]></programlisting>
        </section>
 </chapter>
\ No newline at end of file

Reply via email to