Skip to content

livekit-wakeword: runtime-configurable ONNX session options - #1372

Open
pham-tuan-binh wants to merge 2 commits into
mainfrom
wakeword/runtime-session-options
Open

livekit-wakeword: runtime-configurable ONNX session options#1372
pham-tuan-binh wants to merge 2 commits into
mainfrom
wakeword/runtime-session-options

Conversation

@pham-tuan-binh

Copy link
Copy Markdown
Contributor

Before you submit your PR

Make sure the following is true before submitting your PR:

  • I have read the contributing guidelines and validated that this PR will be accepted.
  • I have read and followed the principles regarding breaking changes, testing, and code quality.

PR description

livekit-wakeword builds every session with a bare Session::builder():

Ok(Session::builder()?.commit_from_memory(bytes)?)

so how much CPU wake word detection uses, and whether the ONNX graph is optimized at
all, are both fixed by the crate. The Python SDK takes a sess_options parameter for
exactly this (livekit-wakeword#73),
which lets a caller run detection as a soft background process:

opts = ort.SessionOptions()
opts.intra_op_num_threads = 1
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
opts.add_session_config_entry("session.intra_op.allow_spinning", "0")
model = WakeWordModel(models=model_files, sess_options=opts)

This adds the Rust equivalent, supplied at runtime:

let options = SessionOptions {
    intra_threads: Some(1),
    inter_threads: Some(1),
    parallel_execution: Some(false),
    intra_op_spinning: Some(false),
    inter_op_spinning: Some(false),
    ..Default::default()
};
let model = WakeWordModel::with_session_options(&models, 16000, options)?;

SessionOptions is applied to the two bundled feature extraction models, to every
classifier passed to the constructor, and — since it is stored on the model — to any
classifier a later load_model call adds. load_model_with_session_options overrides
it for a single classifier, matching Python's per-call sess_options.

Why the crate's own type rather than ort::SessionBuilder

ort is at 2.0.0-rc.11, so exposing its types would make every rc bump a breaking
change for consumers of this crate, and the type buys little here — see below.

Unsupported options are skipped, not fatal

ort-tract, used on every target except aarch64 Windows, implements only
SetSessionGraphOptimizationLevel (its SessionOptions is literally
{ perform_optimizations: bool }); every other session option falls through to
ort-sys' stub API and returns ORT_NOT_IMPLEMENTED. Applied naively, the Python
snippet above would fail to build a session at all on the default backend. Each
option other than the optimization level is therefore applied best-effort, and a
NotImplemented result leaves the builder unchanged. That is not just leniency:
tract runs single-threaded and never spin-waits, so intra_threads: Some(1) and
intra_op_spinning: Some(false) already describe what it does — the request is
satisfied, just not expressible.

Default is Level3

SessionOptions::default() requests OptimizationLevel::Level3, ONNX Runtime's own
default, so the native backend on aarch64 Windows is unaffected. On the tract path it
is a large change, because tract runs into_optimized() only when a session asks for
some level of optimization:

// ort-tract 0.2.0, api.rs:168
options.perform_optimizations = graph_optimization_level != GraphOptimizationLevel::ORT_DISABLE_ALL;

// ort-tract 0.2.0, session.rs:63
let graph = Arc::new(if options.perform_optimizations { model.into_optimized()? } else { model.into_typed()? });

This subsumes #1368, which sets the same level as a constant; whichever lands second
needs a trivial rebase.

Breaking changes

None. new and load_model keep their signatures and delegate to the new methods
with SessionOptions::default(). Wake word scores are unchanged; the default
optimization level is a speedup, not a behaviour change (asserted below).

MSRV

Unchanged.

Testing

cargo test -p livekit-wakeword --release — 5 integration tests plus the
SessionOptions doctest, all passing. Two tests are new:

  • test_session_options_preserve_scores — the conservative-CPU options above,
    including a config entry, produce a byte-identical score to the default model.
    This is the regression test for the best-effort path: on any tract target it fails
    to construct at all if an unimplemented option is treated as an error.
  • test_optimization_levels_and_per_model_optionsDisable, Level1 and Level3
    each build a model that still scores positive.wav above threshold, and a
    per-classifier override loads alongside a default-options classifier with equal
    scores.

Neither test asserts on timing, so I measured separately that the level really does
reach the backend — 7 runs of predict() over a 2 s window, same process, release
build, Apple M-series:

optimization level median predict
Disable (what main does today) 329 ms
Level3 (the new default) 53 ms

6.2x, consistent with the numbers in #1368. Laptop timings, so treat the magnitude as
the claim rather than the figures.

Async

No change. The diff introduces no .await and no runtime dependency.

Sessions were built with a bare `Session::builder()`, so callers had no way to
tune them: how much CPU wake word detection uses, and whether the graph is
optimized at all, were both fixed by the crate. The Python SDK takes a
`sess_options` parameter for exactly this.

`SessionOptions` now describes how every session is created — graph optimization
level, intra/inter-op threads, sequential execution, thread spinning, and
arbitrary session config entries — and is passed at runtime to
`WakeWordModel::with_session_options` or `load_model_with_session_options`.
`Default` requests `OptimizationLevel::Level3`, ONNX Runtime's own default.

Options the active backend does not implement are skipped rather than failing
session creation: `ort-tract` implements only the optimization level, and since
it runs single-threaded without spin-waiting, a request to limit threads or
disable spinning already describes what it does.
@pham-tuan-binh
pham-tuan-binh requested a review from ladvoc as a code owner August 28, 2026 09:53
@github-actions

Copy link
Copy Markdown
Contributor

Changeset ✓

This PR includes a changeset covering all affected packages:

Package Bump
livekit-wakeword minor

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

`OptimizationLevel` mirrored `ort`'s `GraphOptimizationLevel` one variant at a
time to add a `Default`. Since `ort::Error` is already part of the crate's public
API there was no abstraction left to protect, so the enum is now re-exported —
the way `livekit` re-exports libwebrtc's `DegradationPreference` — and `Level3`
moves into a hand-written `Default for SessionOptions`.

`best_effort` cloned the `SessionBuilder` before every option so it could roll
back on `NotImplemented`, but the backend is not a runtime property: `build.rs`
emits `use_tract` for every target except aarch64 Windows, and
`ensure_tract_backend` is already gated on it. `apply` is now two `#[cfg]` bodies
— tract sets the optimization level and nothing else, real ONNX Runtime applies
every option and treats a failure as the error it is.

`load_model_with_session_options` is dropped. Per-classifier tuning is
speculative when the classifiers are the tiny models, and it was the only reason
`load_model` cloned the stored options to satisfy the borrow checker;
`test_load_model_inherits_session_options` keeps the post-construction
`load_model` path covered. `build_session_from_file` delegates to
`build_session_from_memory` rather than repeating the tract init and commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant