Skip to main content

onnx_runtime_ep_api/
kernel.rs

1//! The [`Kernel`] trait and kernel-match / cost types (§4.2).
2
3use std::borrow::Cow;
4
5use onnx_runtime_ir::TensorLayout;
6
7use crate::error::Result;
8use crate::tensor::{TensorMut, TensorView};
9use crate::weight::WeightHandle;
10
11/// A cost estimate for running a kernel, consumed by the placement cost model
12/// (`docs/ORT2.md` §6). All time fields are in **microseconds**; a fuller model
13/// (roofline, calibration) lands in `onnx-runtime-cost-model` (Phase 2).
14///
15/// The struct is `#[non_exhaustive]`: the Phase-2 cost model may add fields
16/// (e.g. energy, occupancy) without breaking EP crates. Construct it via
17/// [`Cost::ZERO`], [`Cost::new`], or the `with_*` builders rather than a struct
18/// literal so those additions stay source-compatible.
19///
20/// The three time components (`compute_us`, `memory_us`, `transfer_us`) map onto
21/// the design's *compute*, *memory-traffic*, and *layout/transfer* estimates;
22/// `launch_us` captures fixed dispatch latency (§6.2 `launch_overhead`) and
23/// `bytes_moved` carries the raw memory-traffic figure a roofline model needs
24/// (§6.3, mirroring the design `Cost::memory_bytes`).
25#[derive(Clone, Copy, Debug, Default, PartialEq)]
26#[non_exhaustive]
27pub struct Cost {
28    /// Estimated compute time (µs).
29    pub compute_us: f64,
30    /// Estimated memory-traffic time (µs).
31    pub memory_us: f64,
32    /// Estimated layout-conversion / cross-device copy time at boundaries (µs).
33    pub transfer_us: f64,
34    /// Fixed kernel-launch / dispatch latency (µs), independent of size.
35    pub launch_us: f64,
36    /// Estimated bytes of memory traffic (for roofline / bandwidth models).
37    pub bytes_moved: u64,
38}
39
40impl Cost {
41    /// The zero cost (free op).
42    pub const ZERO: Cost = Cost {
43        compute_us: 0.0,
44        memory_us: 0.0,
45        transfer_us: 0.0,
46        launch_us: 0.0,
47        bytes_moved: 0,
48    };
49
50    /// A cost from its three time components; `launch_us` and `bytes_moved`
51    /// default to zero (set them via the builders).
52    pub fn new(compute_us: f64, memory_us: f64, transfer_us: f64) -> Self {
53        Self {
54            compute_us,
55            memory_us,
56            transfer_us,
57            ..Self::ZERO
58        }
59    }
60
61    /// Set the fixed launch/dispatch latency.
62    pub fn with_launch_us(mut self, launch_us: f64) -> Self {
63        self.launch_us = launch_us;
64        self
65    }
66
67    /// Set the estimated memory-traffic volume.
68    pub fn with_bytes_moved(mut self, bytes_moved: u64) -> Self {
69        self.bytes_moved = bytes_moved;
70        self
71    }
72
73    /// Total estimated wall time (µs): the sum of all time components.
74    pub fn total_us(&self) -> f64 {
75        self.compute_us + self.memory_us + self.transfer_us + self.launch_us
76    }
77}
78
79/// Result of [`crate::ExecutionProvider::supports_op`].
80///
81/// A decline reason travels with the decision that produced it. EPs must not
82/// maintain a separate reason table: colocating the reason with `Unsupported`
83/// keeps diagnostics from drifting away from the actual claim predicate.
84pub enum KernelMatch {
85    Supported {
86        cost: Cost,
87        /// Layouts the kernel requires for each input, if constrained.
88        required_input_layouts: Option<Vec<TensorLayout>>,
89        /// Layouts the kernel produces for each output.
90        output_layouts: Vec<TensorLayout>,
91    },
92    Unsupported {
93        /// Actionable explanation of what the EP accepts or how to fix fallback.
94        reason: Cow<'static, str>,
95    },
96}
97
98impl KernelMatch {
99    /// Construct an unsupported match with its actionable decline reason.
100    pub fn unsupported(reason: impl Into<Cow<'static, str>>) -> Self {
101        Self::Unsupported {
102            reason: reason.into(),
103        }
104    }
105
106    /// Whether the op is supported.
107    pub fn is_supported(&self) -> bool {
108        matches!(self, KernelMatch::Supported { .. })
109    }
110
111    /// The EP's decline reason, or `None` when the op is supported.
112    pub fn reason(&self) -> Option<&str> {
113        match self {
114            Self::Supported { .. } => None,
115            Self::Unsupported { reason } => Some(reason),
116        }
117    }
118}
119
120/// Whether a compiled kernel can participate in device-graph capture.
121///
122/// As with [`KernelMatch`], a decline reason travels with the decision that
123/// produced it. EPs must not maintain a separate reason table: capture
124/// eligibility is often shape-, dtype-, warmup-, or implementation-dependent,
125/// so separating the reason from the predicate would let diagnostics drift.
126#[derive(Clone, Debug, PartialEq, Eq)]
127pub enum CaptureSupport {
128    /// The kernel's warmed execution path satisfies the device-graph contract.
129    Supported,
130    /// The kernel cannot currently be captured.
131    Unsupported {
132        /// Actionable explanation of the failed capture precondition.
133        reason: Cow<'static, str>,
134    },
135}
136
137impl CaptureSupport {
138    /// Construct an unsupported result with its actionable decline reason.
139    pub fn unsupported(reason: impl Into<Cow<'static, str>>) -> Self {
140        Self::Unsupported {
141            reason: reason.into(),
142        }
143    }
144
145    /// Whether the kernel can currently participate in device-graph capture.
146    pub fn is_supported(&self) -> bool {
147        matches!(self, Self::Supported)
148    }
149
150    /// The kernel's capture-decline reason, or `None` when capture is supported.
151    pub fn reason(&self) -> Option<&str> {
152        match self {
153            Self::Supported => None,
154            Self::Unsupported { reason } => Some(reason),
155        }
156    }
157}
158
159/// The concrete implementation selected by a kernel's internal dispatcher.
160///
161/// Unlike [`KernelMatch`] (the EP's claim over a node) and [`CaptureSupport`]
162/// (graph-capture eligibility), this records **which** implementation ran for an
163/// already-claimed node and **why** the dispatch predicate chose it. A single
164/// claimed op (e.g. `MatMulNBits`) can pick materially different kernels for the
165/// same shape family, so node-level claims alone do not explain what executed.
166/// The reason travels with the variant so a trace explains both.
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct KernelVariantSelection {
169    /// Stable implementation name suitable for filtering and aggregation.
170    pub variant: &'static str,
171    /// Human-readable explanation of the dispatch predicate that selected it.
172    pub reason: Cow<'static, str>,
173}
174
175impl KernelVariantSelection {
176    /// Construct a selected variant with its dispatch reason.
177    pub fn new(variant: &'static str, reason: impl Into<Cow<'static, str>>) -> Self {
178        Self {
179            variant,
180            reason: reason.into(),
181        }
182    }
183}
184
185/// Trace-argument key carrying the selected kernel implementation.
186pub const ARG_KERNEL_VARIANT: &str = "kernel_variant";
187/// Trace-argument key carrying why the kernel variant was selected.
188pub const ARG_KERNEL_VARIANT_REASON: &str = "kernel_variant_reason";
189
190/// Whether kernel-variant trace annotations would currently be recorded.
191///
192/// This is true only when an enabled executor op-span is active on the current
193/// thread, so callers can guard shape-dependent reason formatting behind it and
194/// keep the disabled dispatch path allocation-free. It is the cheap thread-local
195/// check that closes the "dead write" gap: without a live span, an annotation
196/// has nothing to attach to, so there is no point formatting a reason.
197#[must_use]
198#[inline]
199pub fn kernel_variant_tracing_enabled() -> bool {
200    onnx_runtime_tracer::tracing_active()
201}
202
203/// Record a selected kernel variant on the active runtime trace span.
204///
205/// The annotation enriches the per-op span the executor opens for a traced run,
206/// so it carries the node identity already. Callers should normally use
207/// [`record_kernel_variant!`] so formatted reasons are built only when a span is
208/// active.
209#[inline]
210pub fn record_kernel_variant_selection(selection: &KernelVariantSelection) {
211    if !kernel_variant_tracing_enabled() {
212        return;
213    }
214    onnx_runtime_tracer::annotate_current_span_with(|| {
215        onnx_runtime_tracer::Args::new()
216            .with(ARG_KERNEL_VARIANT, selection.variant)
217            .with(ARG_KERNEL_VARIANT_REASON, selection.reason.as_ref())
218    });
219}
220
221/// Record a selected kernel variant for a named *sub-decision* of the current
222/// op.
223///
224/// A single claimed node can make several independent kernel-path choices in
225/// sequence (e.g. `GroupQueryAttention` picks a prep-fusion path *and* an
226/// attention-backend path). Recording each under the shared
227/// [`ARG_KERNEL_VARIANT`] key would let a later choice overwrite an earlier one
228/// on the same span, so each sub-decision is namespaced by `stage` into
229/// `kernel_variant.<stage>` / `kernel_variant_reason.<stage>`. Use the plain
230/// [`record_kernel_variant_selection`] for the node's terminal/primary variant.
231#[inline]
232pub fn record_kernel_variant_stage_selection(stage: &str, selection: &KernelVariantSelection) {
233    if !kernel_variant_tracing_enabled() {
234        return;
235    }
236    let variant_key = format!("{ARG_KERNEL_VARIANT}.{stage}");
237    let reason_key = format!("{ARG_KERNEL_VARIANT_REASON}.{stage}");
238    onnx_runtime_tracer::annotate_current_span_with(|| {
239        onnx_runtime_tracer::Args::new()
240            .with(variant_key, selection.variant)
241            .with(reason_key, selection.reason.as_ref())
242    });
243}
244
245/// Record a selected kernel implementation and lazily formatted reason on the
246/// active executor op-span.
247///
248/// Formatting is skipped entirely unless a span is active
249/// ([`kernel_variant_tracing_enabled`]), so instrumented dispatch sites stay
250/// allocation-free on the hot decode path when tracing is off.
251#[macro_export]
252macro_rules! record_kernel_variant {
253    ($variant:expr, $($arg:tt)+) => {{
254        if $crate::kernel_variant_tracing_enabled() {
255            let selection = $crate::KernelVariantSelection::new(
256                $variant,
257                ::std::format!($($arg)+),
258            );
259            $crate::record_kernel_variant_selection(&selection);
260        }
261    }};
262}
263
264/// Record a named *sub-decision* kernel variant on the active executor op-span.
265///
266/// Like [`record_kernel_variant!`] but namespaces the annotation under `$stage`
267/// so multiple kernel-path choices made while executing one node do not clobber
268/// each other. Formatting is skipped entirely unless a span is active.
269#[macro_export]
270macro_rules! record_kernel_variant_stage {
271    ($stage:expr, $variant:expr, $($arg:tt)+) => {{
272        if $crate::kernel_variant_tracing_enabled() {
273            let selection = $crate::KernelVariantSelection::new(
274                $variant,
275                ::std::format!($($arg)+),
276            );
277            $crate::record_kernel_variant_stage_selection($stage, &selection);
278        }
279    }};
280}
281
282/// Decline the current capture-support query with an actionable reason.
283///
284/// Formatting is evaluated only on the decline path.
285#[macro_export]
286macro_rules! decline_capture {
287    ($($arg:tt)+) => {
288        return $crate::CaptureSupport::unsupported(format!($($arg)+))
289    };
290}
291
292/// Require a capture precondition, declining with an actionable reason when false.
293///
294/// Formatting is evaluated only when the condition fails.
295#[macro_export]
296macro_rules! require_capture {
297    ($condition:expr, $($arg:tt)+) => {
298        if !$condition {
299            $crate::decline_capture!($($arg)+);
300        }
301    };
302}
303
304/// Decline the current `supports_op` call with an actionable reason.
305///
306/// Formatting is evaluated only on the decline path.
307#[macro_export]
308macro_rules! deny {
309    ($($arg:tt)+) => {
310        return $crate::KernelMatch::unsupported(format!($($arg)+))
311    };
312}
313
314/// Require a claim condition, declining with an actionable reason when false.
315///
316/// Formatting is evaluated only when the condition fails, keeping the hot
317/// supported path allocation-free.
318#[macro_export]
319macro_rules! require {
320    ($condition:expr, $($arg:tt)+) => {
321        if !$condition {
322            $crate::deny!($($arg)+);
323        }
324    };
325}
326
327/// A zero-copy **view output**: a kernel's declaration that one of its outputs
328/// is a strided view aliasing one of its inputs' buffers, rather than freshly
329/// computed bytes (`docs/ORT2.md` §5.4, lazy PyTorch-style views).
330///
331/// The `shape` / `strides` / `byte_offset` describe the output tensor relative
332/// to the **same base pointer** as the referenced input view (i.e. relative to
333/// the input's backing allocation, honoring any offset that input itself
334/// already carried). Strides are in **elements** and may be negative (DLPack).
335/// The executor records this metadata against the output value and does **not**
336/// allocate a buffer or invoke the compute path for that slot; the source
337/// buffer is kept alive until the view's consumers have run.
338#[derive(Clone, Debug, PartialEq, Eq)]
339pub struct ViewOutput {
340    /// Positional index (into the kernel's `inputs`) of the aliased input.
341    pub input_index: usize,
342    /// Output shape.
343    pub shape: Vec<usize>,
344    /// Output element strides relative to the aliased input's base pointer.
345    pub strides: Vec<i64>,
346    /// Byte offset of the output element origin from the aliased input's base.
347    pub byte_offset: usize,
348}
349
350/// An executor-delivered kernel input. Existing EPs receive `Tensor` variants;
351/// an EP advertising the `nxrt` capability may receive a lazy `Weight` at the
352/// `pkg.nxrt::BlockQuantizedMoE` boundary.
353pub enum KernelInput<'a> {
354    Tensor(TensorView<'a>),
355    Weight(&'a WeightHandle),
356}
357
358impl<'a> KernelInput<'a> {
359    pub fn tensor(&self) -> Option<&TensorView<'a>> {
360        match self {
361            Self::Tensor(view) => Some(view),
362            Self::Weight(_) => None,
363        }
364    }
365
366    pub fn weight(&self) -> Option<&WeightHandle> {
367        match self {
368            Self::Tensor(_) => None,
369            Self::Weight(weight) => Some(weight),
370        }
371    }
372}
373
374/// A kernel ready to execute a specific op with specific shapes (§4.2).
375pub trait Kernel: Send {
376    /// Tell the kernel which positional inputs are immutable graph constants.
377    ///
378    /// The session calls this exactly once, immediately after construction.
379    /// Kernels may use it to prepack or memoize those inputs. Runtime inputs must
380    /// never be marked constant: caching them would return stale results.
381    fn set_constant_inputs(&mut self, constant_inputs: &[bool]) {
382        let _ = constant_inputs;
383    }
384
385    /// Execute over device-resident inputs/outputs.
386    fn execute(&self, inputs: &[TensorView], outputs: &mut [TensorMut]) -> Result<()>;
387
388    /// Execute through the general weight-delivery seam.
389    ///
390    /// The default adapter accepts only resident tensor inputs and forwards to
391    /// [`Kernel::execute`], so existing EPs compile and behave identically.
392    /// Paging-aware kernels override this method to consume lazy handles.
393    fn execute_with_inputs(
394        &self,
395        inputs: &[KernelInput<'_>],
396        outputs: &mut [TensorMut],
397    ) -> Result<()> {
398        let views = inputs
399            .iter()
400            .map(|input| {
401                input.tensor().copied().ok_or_else(|| {
402                    crate::EpError::KernelFailed(
403                        "kernel received a lazy WeightHandle without implementing \
404                         execute_with_inputs"
405                            .into(),
406                    )
407                })
408            })
409            .collect::<Result<Vec<_>>>()?;
410        self.execute(&views, outputs)
411    }
412
413    /// Estimated FLOPs, if known (for the cost model).
414    fn estimated_flops(&self) -> Option<u64> {
415        None
416    }
417
418    /// Attempt to express this node's outputs as zero-copy [`ViewOutput`]s over
419    /// its inputs instead of computing bytes (the layout/movement-op fast path).
420    ///
421    /// `inputs` carries the real (possibly already-strided) input views and
422    /// `num_outputs` is the node's output arity. Returning:
423    /// * `None` — the default — means "compute normally": the executor allocates
424    ///   output buffers and calls [`Kernel::execute`].
425    /// * `Some(specs)` means every output is a view; `specs.len()` MUST equal
426    ///   `num_outputs`. A kernel that can view some but not all outputs must
427    ///   return `None` (all-or-nothing) so correctness never regresses.
428    ///
429    /// When `Some` is returned, [`Kernel::execute`] is **not** invoked.
430    fn view_outputs(&self, inputs: &[TensorView], num_outputs: usize) -> Option<Vec<ViewOutput>> {
431        let _ = (inputs, num_outputs);
432        None
433    }
434
435    /// Whether the kernel accepts a non-contiguous (strided) input at `idx`.
436    fn supports_strided_input(&self, input_idx: usize) -> bool {
437        let _ = input_idx;
438        false
439    }
440
441    /// The layout the kernel writes most efficiently, if it has a preference.
442    fn preferred_output_layout(&self) -> Option<TensorLayout> {
443        None
444    }
445
446    /// Whether this kernel can participate in device-graph capture.
447    ///
448    /// The provided default is supported because capture is a runtime/EP
449    /// capability: kernels that need restrictions override this method and
450    /// return the exact failed precondition.
451    fn capture_support(&self) -> CaptureSupport {
452        CaptureSupport::Supported
453    }
454
455    /// Compatibility shim for existing CUDA-graph callers.
456    ///
457    /// New capture gates must use [`Kernel::capture_support`] so a decline
458    /// reason is never discarded.
459    fn cuda_graph_compatible(&self) -> bool {
460        self.capture_support().is_supported()
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use std::sync::Arc;
467    use std::sync::atomic::{AtomicBool, Ordering};
468
469    use super::*;
470    use crate::{DeviceId, DevicePtr};
471
472    #[test]
473    fn cost_zero_and_total() {
474        assert_eq!(Cost::ZERO.total_us(), 0.0);
475        let c = Cost::new(10.0, 5.0, 2.0);
476        assert_eq!(c.total_us(), 17.0);
477        assert_eq!(c.bytes_moved, 0);
478        assert_eq!(c.launch_us, 0.0);
479    }
480
481    #[test]
482    fn cost_builders_are_additive() {
483        let c = Cost::new(10.0, 5.0, 2.0)
484            .with_launch_us(3.0)
485            .with_bytes_moved(4096);
486        // launch time folds into the total; bytes_moved is metadata for roofline.
487        assert_eq!(c.total_us(), 20.0);
488        assert_eq!(c.bytes_moved, 4096);
489    }
490
491    #[test]
492    fn kernel_match_reports_support() {
493        let supported = KernelMatch::Supported {
494            cost: Cost::new(1.0, 0.0, 0.0),
495            required_input_layouts: None,
496            output_layouts: vec![],
497        };
498        assert!(supported.is_supported());
499        assert_eq!(supported.reason(), None);
500
501        let unsupported = KernelMatch::unsupported("test EP supports no ops");
502        assert!(!unsupported.is_supported());
503        assert_eq!(unsupported.reason(), Some("test EP supports no ops"));
504    }
505
506    fn require_positive(value: i32) -> KernelMatch {
507        require!(value > 0, "value must be positive, got {value}");
508        KernelMatch::Supported {
509            cost: Cost::ZERO,
510            required_input_layouts: None,
511            output_layouts: vec![],
512        }
513    }
514
515    #[test]
516    fn require_macro_carries_formatted_decline_reason() {
517        let rejected = require_positive(-2);
518        assert_eq!(rejected.reason(), Some("value must be positive, got -2"));
519        assert!(require_positive(2).is_supported());
520    }
521
522    #[test]
523    fn kernel_variant_selection_carries_winner_and_reason() {
524        let selection =
525            KernelVariantSelection::new("gemv", "M==1 decode uses the packed int4 GEMV path");
526        assert_eq!(selection.variant, "gemv");
527        assert_eq!(
528            selection.reason,
529            "M==1 decode uses the packed int4 GEMV path"
530        );
531    }
532
533    #[test]
534    fn record_kernel_variant_without_active_span_skips_formatting() {
535        // No executor span is open here, so `tracing_active()` is false: the
536        // macro must not evaluate its format arguments (the dead-write guard).
537        let formatted = AtomicBool::new(false);
538        record_kernel_variant!("gemv", "{}", {
539            formatted.store(true, Ordering::Relaxed);
540            "M==1 decode"
541        });
542        assert!(!formatted.load(Ordering::Relaxed));
543    }
544
545    #[test]
546    fn record_kernel_variant_annotates_active_span() {
547        use onnx_runtime_tracer::TraceContext;
548        let (trace, events) = TraceContext::in_memory();
549        {
550            let _span = trace.span("MatMulNBits", "op");
551            record_kernel_variant!(
552                "gemv_f16",
553                "K={}, N={} chose the vectorized GEMV",
554                4864,
555                896
556            );
557        }
558        let events = events.events();
559        assert_eq!(events.len(), 1);
560        let args = events[0].args.as_ref().unwrap();
561        assert_eq!(args[ARG_KERNEL_VARIANT], "gemv_f16");
562        assert!(
563            args[ARG_KERNEL_VARIANT_REASON]
564                .as_str()
565                .unwrap()
566                .contains("K=4864, N=896")
567        );
568    }
569
570    #[test]
571    fn record_kernel_variant_stage_namespaces_multiple_subdecisions() {
572        use onnx_runtime_tracer::TraceContext;
573        let (trace, events) = TraceContext::in_memory();
574        {
575            let _span = trace.span("GroupQueryAttention", "op");
576            // Two independent sub-decisions on the same node: the staged prep
577            // choice must survive the terminal attention-backend choice rather
578            // than being overwritten by the shared key.
579            record_kernel_variant_stage!("prep", "gqa_prep_fused", "Sq==1, even head_dim");
580            record_kernel_variant!("attention_gqa_decode_fp16_splitk", "split-K decode");
581        }
582        let events = events.events();
583        assert_eq!(events.len(), 1);
584        let args = events[0].args.as_ref().unwrap();
585        assert_eq!(args[ARG_KERNEL_VARIANT], "attention_gqa_decode_fp16_splitk");
586        assert_eq!(args["kernel_variant.prep"], "gqa_prep_fused");
587        assert!(
588            args["kernel_variant_reason.prep"]
589                .as_str()
590                .unwrap()
591                .contains("even head_dim")
592        );
593    }
594
595    struct DecliningCaptureKernel;
596
597    impl Kernel for DecliningCaptureKernel {
598        fn execute(&self, _inputs: &[TensorView], _outputs: &mut [TensorMut]) -> Result<()> {
599            Ok(())
600        }
601
602        fn capture_support(&self) -> CaptureSupport {
603            CaptureSupport::unsupported("per-call workspace allocation is not capturable")
604        }
605    }
606
607    #[test]
608    fn cuda_graph_compatible_shim_matches_capture_support() {
609        let supported = LegacyKernel {
610            called: Arc::new(AtomicBool::new(false)),
611        };
612        assert_eq!(
613            supported.cuda_graph_compatible(),
614            supported.capture_support().is_supported()
615        );
616
617        let unsupported = DecliningCaptureKernel;
618        assert_eq!(
619            unsupported.cuda_graph_compatible(),
620            unsupported.capture_support().is_supported()
621        );
622        assert_eq!(
623            unsupported.capture_support().reason(),
624            Some("per-call workspace allocation is not capturable")
625        );
626    }
627
628    struct LegacyKernel {
629        called: Arc<AtomicBool>,
630    }
631
632    impl Kernel for LegacyKernel {
633        fn execute(&self, inputs: &[TensorView], _outputs: &mut [TensorMut]) -> Result<()> {
634            assert_eq!(inputs.len(), 1);
635            assert_eq!(inputs[0].shape, &[4]);
636            self.called.store(true, Ordering::Relaxed);
637            Ok(())
638        }
639    }
640
641    #[test]
642    fn legacy_kernel_adapter_receives_the_resident_tensor_path() {
643        let called = Arc::new(AtomicBool::new(false));
644        let kernel = LegacyKernel {
645            called: Arc::clone(&called),
646        };
647        let bytes = [1u8, 2, 3, 4];
648        let shape = [4usize];
649        let strides = [1i64];
650        let inputs = [KernelInput::Tensor(TensorView::new(
651            DevicePtr(bytes.as_ptr().cast()),
652            onnx_runtime_ir::DataType::Uint8,
653            &shape,
654            &strides,
655            DeviceId::cpu(),
656        ))];
657
658        kernel.execute_with_inputs(&inputs, &mut []).unwrap();
659        assert!(called.load(Ordering::Relaxed));
660    }
661}