Skip to main content

vortex_array/
executor.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Iterative array execution.
5//!
6//! The single-step [`Executable`] implementation for [`ArrayRef`] tries `reduce`,
7//! `reduce_parent`, `execute_parent`, then `execute` once. The matcher-driven
8//! [`ArrayRef::execute_until`] loop interprets [`ExecutionStep::ExecuteSlot`],
9//! [`ExecutionStep::AppendChild`], and [`ExecutionStep::Done`] using an explicit stack plus an
10//! optional builder, so encodings can advance without recursive descent.
11//!
12//! See <https://docs.vortex.dev/developer-guide/internals/execution> for the full execution
13//! narrative, diagrams, and walkthroughs.
14
15use std::env::VarError;
16use std::fmt;
17use std::fmt::Display;
18use std::sync::Arc;
19use std::sync::LazyLock;
20#[cfg(debug_assertions)]
21use std::sync::atomic::AtomicUsize;
22#[cfg(debug_assertions)]
23use std::sync::atomic::Ordering;
24
25use vortex_error::VortexExpect;
26use vortex_error::VortexResult;
27use vortex_error::vortex_bail;
28use vortex_error::vortex_ensure;
29use vortex_error::vortex_panic;
30use vortex_session::VortexSession;
31
32use crate::AnyCanonical;
33use crate::ArrayRef;
34use crate::Canonical;
35use crate::IntoArray;
36use crate::array::ArrayId;
37use crate::builders::ArrayBuilder;
38use crate::builders::builder_with_capacity_in;
39use crate::dtype::DType;
40use crate::matcher::Matcher;
41use crate::memory::HostAllocatorRef;
42use crate::memory::MemorySessionExt;
43use crate::optimizer::ArrayOptimizer;
44use crate::optimizer::kernels::ArrayKernelsExt;
45use crate::optimizer::kernels::ParentExecutionKernels;
46use crate::optimizer::kernels::execute_parent_key;
47use crate::stats::ArrayStats;
48use crate::stats::StatsSet;
49use crate::trace_op;
50
51/// Returns the maximum number of iterations to attempt when executing an array before giving up and returning
52/// an error, can be by the `VORTEX_MAX_ITERATIONS` env variables, otherwise defaults to 2^22.
53pub(crate) fn max_iterations() -> usize {
54    static MAX_ITERATIONS: LazyLock<usize> =
55        LazyLock::new(|| match std::env::var("VORTEX_MAX_ITERATIONS") {
56            Ok(val) => val.parse::<usize>().unwrap_or_else(|e| {
57                vortex_panic!("VORTEX_MAX_ITERATIONS is not a valid usize: {e}")
58            }),
59            Err(VarError::NotPresent) => 2 << 21, // 2 ^ 22
60            Err(VarError::NotUnicode(_)) => {
61                vortex_panic!("VORTEX_MAX_ITERATIONS is not a valid unicode string")
62            }
63        });
64    *MAX_ITERATIONS
65}
66
67/// Marker trait for types that an [`ArrayRef`] can be executed into.
68///
69/// Implementors must provide an implementation of `execute` that takes
70/// an [`ArrayRef`] and an [`ExecutionCtx`], and produces an instance of the
71/// implementor type.
72///
73/// Users should use the `Array::execute` or `Array::execute_as` methods
74pub trait Executable: Sized {
75    fn execute(array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Self>;
76}
77
78#[expect(clippy::same_name_method)]
79impl ArrayRef {
80    /// Execute this array to produce an instance of `E`.
81    ///
82    /// See the [`Executable`] implementation for details on how this execution is performed.
83    pub fn execute<E: Executable>(self, ctx: &mut ExecutionCtx) -> VortexResult<E> {
84        E::execute(self, ctx)
85    }
86
87    /// Execute this array, labeling the execution step with a name for tracing.
88    pub fn execute_as<E: Executable>(
89        self,
90        _name: &'static str,
91        ctx: &mut ExecutionCtx,
92    ) -> VortexResult<E> {
93        E::execute(self, ctx)
94    }
95
96    /// Iteratively execute this array until the [`Matcher`] matches, using an explicit work
97    /// stack plus an optional builder for `AppendChild`.
98    ///
99    /// Note: the returned array may not match `M`. If execution converges to a canonical form
100    /// that does not match `M`, the canonical array is returned since no further execution
101    /// progress is possible.
102    ///
103    /// For safety, this errors once execution reaches a configurable maximum number of
104    /// iterations (default `2^22`, override with `VORTEX_MAX_ITERATIONS`).
105    ///
106    /// # Loop state
107    ///
108    /// - `current_array: ArrayRef` -- the array currently in focus.
109    /// - `current_builder: Option<Box<dyn ArrayBuilder>>` -- active only for builder-mode
110    ///   execution. `AppendChild` appends detached children here. `Done` finishes the builder
111    ///   and turns it back into the next `current_array`.
112    /// - `stack: Vec<StackFrame>` -- suspended parents from `ExecuteSlot`, including the
113    ///   detached slot index, its [`DonePredicate`], and the parent builder that was active
114    ///   before focus moved into the child.
115    ///
116    /// Example after `ExecuteSlot(1, pred)` has focused slot 1 of a parent:
117    ///
118    /// ```text
119    ///   stack[top].parent_array:
120    ///     RunEnd                          <-- suspended parent
121    ///     +-- slot 0: ends
122    ///     +-- slot 1: _  (detached)
123    ///
124    ///   current_array:
125    ///     DictEncoding                    <-- focused child
126    ///     +-- slot 0: codes
127    ///     +-- slot 1: dictionary
128    ///
129    ///   current_builder:
130    ///     None
131    /// ```
132    ///
133    /// Each loop iteration works like this:
134    ///
135    /// ```text
136    /// loop:
137    ///   Step 1: done(current_array)?
138    ///     - root activation   -> return current_array
139    ///     - ExecuteSlot frame -> pop, reattach child, resume parent
140    ///
141    ///   Step 2: current_builder active?
142    ///     - yes -> skip Step 2a / 2b
143    ///     - no  -> try parent kernels
144    ///
145    ///   Step 2a: if stack.top exists:
146    ///               parent = stack.top.parent_array
147    ///               child = current_array
148    ///               kernels[(parent.encoding_id(), child.encoding_id())]
149    ///                 .try_execute_parent(child, parent, stack.top.slot_idx)
150    ///
151    ///   Step 2b: for child in current_array.children():
152    ///               parent = current_array
153    ///               kernels[(parent.encoding_id(), child.encoding_id())]
154    ///                 .try_execute_parent(child, parent, child.slot_idx)
155    ///
156    ///   Step 3: match current_array.execute()
157    ///     ExecuteSlot(i, pred) -> push parent on stack, focus child `i`
158    ///     AppendChild(i)       -> detach child `i`, append it into current_builder,
159    ///                             keep parent as current_array
160    ///     Done                 -> finish current_builder if present, else use returned array
161    /// ```
162    ///
163    /// Step 2a and Step 2b are skipped while `current_builder` is active. `AppendChild`
164    /// partially consumes `current_array`: some slots already live in the builder, so a
165    /// parent rewrite would observe inconsistent state and could discard accumulated builder
166    /// data.
167    #[allow(clippy::cognitive_complexity)]
168    pub fn execute_until<M: Matcher>(self, ctx: &mut ExecutionCtx) -> VortexResult<ArrayRef> {
169        let mut current_array = self;
170        let mut current_builder: Option<Box<dyn ArrayBuilder>> = None;
171        let mut stack: Vec<StackFrame> = Vec::new();
172        let execute_parent_kernels = Arc::clone(&ctx.execute_parent_kernels);
173        let kernels = execute_parent_kernels.as_ref();
174        let max_iterations = max_iterations();
175
176        trace_op!(record_execute_until_start::<M>(&current_array));
177
178        for _iteration in 0..max_iterations {
179            trace_op!(record_execute_until_iteration(
180                _iteration,
181                &current_array,
182                stack
183                    .last()
184                    .map(|frame| (&frame.parent_array, frame.slot_idx)),
185                current_builder.is_some(),
186            ));
187
188            let is_done = stack
189                .last()
190                .map_or(M::matches as DonePredicate, |frame| frame.done);
191
192            let done_target = is_done(&current_array);
193            let done_canonical = AnyCanonical::matches(&current_array);
194            trace_op!(record_execute_until_done_check(done_target, done_canonical));
195
196            if done_target || done_canonical {
197                match stack.pop() {
198                    None => {
199                        debug_assert!(
200                            current_builder.is_none(),
201                            "root activation should not retain a builder"
202                        );
203                        trace_op!(record_execute_until_return(&current_array));
204                        return Ok(current_array);
205                    }
206                    Some(frame) => {
207                        let _slot_idx = frame.slot_idx;
208                        (current_array, current_builder) = pop_frame(frame, current_array)?;
209                        trace_op!(record_execute_until_pop_frame(_slot_idx, &current_array));
210                        continue;
211                    }
212                }
213            }
214
215            // Step 2a: execute_parent against the suspended parent from ExecuteSlot.
216            //
217            // When executing a child for ExecuteSlot, try execute_parent against
218            // the suspended parent on the stack. This lets kernels like RunEnd's
219            // FilterKernel fire before the child is forced to canonical.
220            //
221            // Skip when a builder is active: the current array has been partially
222            // consumed by AppendChild (some slots are already in the builder), so
223            // a parent rewrite would see inconsistent state and the builder data
224            // would be lost when we restore frame.parent_builder.
225            if current_builder.is_none()
226                && let Some(frame) = stack.last()
227                && let Some(result) = {
228                    execute_parent_for_child(
229                        "stack_execute_parent",
230                        &frame.parent_array,
231                        &current_array,
232                        frame.slot_idx,
233                        kernels,
234                        ctx,
235                    )?
236                }
237            {
238                let frame = stack.pop().vortex_expect("just peeked");
239                let optimized = result.optimize_ctx(ctx.session())?;
240                trace_op!(record_execute_optimized(&result, &optimized));
241                current_array = optimized;
242                current_builder = frame.parent_builder;
243                continue;
244            }
245            if current_builder.is_none() && stack.last().is_some() {
246                trace_op!(record_execute_parent_none(
247                    "stack_execute_parent",
248                    &current_array,
249                ));
250            }
251
252            // Step 2b: execute_parent against current_array's own children.
253            if current_builder.is_none()
254                && let Some(rewritten) = try_execute_parent(&current_array, kernels, ctx)?
255            {
256                let optimized = rewritten.optimize_ctx(ctx.session())?;
257                trace_op!(record_execute_optimized(&rewritten, &optimized));
258                current_array = optimized;
259                continue;
260            }
261            if current_builder.is_none() {
262                trace_op!(record_execute_parent_none(
263                    "child_execute_parent",
264                    &current_array,
265                ));
266            }
267
268            let expected_len = current_array.len();
269            let expected_dtype = current_array.dtype().clone();
270            let stats = current_array.statistics().to_array_stats();
271            let encoding_id = current_array.encoding_id();
272            trace_op!(record_execute_encoding(&current_array));
273            let result = current_array.execute_encoding_unchecked(ctx)?;
274            let (array, step) = result.into_parts();
275            match step {
276                ExecutionStep::ExecuteSlot(i, done) => {
277                    let (parent, child) = unsafe { array.take_slot_unchecked(i) }?;
278
279                    trace_op!(record_execute_slot(i, &parent, &child));
280                    stack.push(StackFrame {
281                        parent_array: parent,
282                        parent_builder: current_builder.take(),
283                        slot_idx: i,
284                        done,
285                        original_dtype: child.dtype().clone(),
286                        original_len: child.len(),
287                    });
288                    current_array = child;
289                    current_builder = None;
290                }
291                ExecutionStep::AppendChild(i) => {
292                    if current_builder.is_none() {
293                        trace_op!(record_builder_start(&array));
294                        current_builder = Some(builder_with_capacity_in(
295                            ctx.allocator(),
296                            array.dtype(),
297                            array.len(),
298                        ));
299                    }
300                    let (parent, child) = unsafe { array.take_slot_unchecked(i) }?;
301
302                    trace_op!(record_append_child(i, &parent, &child));
303                    trace_op!(record_builder_append(&child));
304
305                    // TODO(joe)[7674]: replace with a builder kernel registry so we don't
306                    // need to go through the VTable append_to_builder indirection.
307                    child.append_to_builder(
308                        current_builder
309                            .as_deref_mut()
310                            .vortex_expect("builder must exist"),
311                        ctx,
312                    )?;
313                    current_array = parent;
314                }
315                ExecutionStep::Done => {
316                    let had_builder = current_builder.is_some();
317                    trace_op!(record_execute_done(&array));
318                    (current_array, current_builder) = finalize_done(
319                        array,
320                        current_builder,
321                        expected_len,
322                        expected_dtype,
323                        stats,
324                        encoding_id,
325                    )?;
326                    if had_builder {
327                        trace_op!(record_builder_finish(&current_array));
328                    }
329                }
330            }
331        }
332
333        vortex_bail!(
334            "Exceeded maximum execution iterations ({}) while executing array",
335            max_iterations,
336        )
337    }
338}
339
340struct StackFrame {
341    parent_array: ArrayRef,
342    parent_builder: Option<Box<dyn ArrayBuilder>>,
343    slot_idx: usize,
344    done: DonePredicate,
345    original_dtype: DType,
346    original_len: usize,
347}
348
349/// Execution context for batch CPU compute.
350#[derive(Debug, Clone)]
351pub struct ExecutionCtx {
352    session: VortexSession,
353    execute_parent_kernels: Arc<ParentExecutionKernels>,
354    #[cfg(debug_assertions)]
355    id: usize,
356    #[cfg(debug_assertions)]
357    ops: Vec<String>,
358}
359
360impl ExecutionCtx {
361    /// Create a new execution context with the given session.
362    ///
363    /// This captures a snapshot of the session's execute-parent kernel registry. Kernels
364    /// registered after this context is created are not visible to it; create a new
365    /// [`ExecutionCtx`] after registration to use newly registered kernels.
366    pub fn new(session: VortexSession) -> Self {
367        let execute_parent_kernels = session.kernels().execute_parent_snapshot();
368        Self {
369            session,
370            execute_parent_kernels,
371            #[cfg(debug_assertions)]
372            id: {
373                static EXEC_CTX_ID: AtomicUsize = AtomicUsize::new(0);
374                EXEC_CTX_ID.fetch_add(1, Ordering::Relaxed)
375            },
376            #[cfg(debug_assertions)]
377            ops: Vec::new(),
378        }
379    }
380
381    /// Get the session associated with this execution context.
382    pub fn session(&self) -> &VortexSession {
383        &self.session
384    }
385
386    /// Get the session-scoped host allocator for this execution context.
387    pub fn allocator(&self) -> HostAllocatorRef {
388        self.session.allocator()
389    }
390
391    /// Log an execution step at the current depth.
392    ///
393    /// Steps are accumulated and dumped as a single trace on Drop at DEBUG level.
394    /// Individual steps are also logged at TRACE level for real-time following.
395    ///
396    /// Use the [`format_args!`] macro to create the `msg` argument.
397    pub fn log(&mut self, msg: fmt::Arguments<'_>) {
398        #[cfg(debug_assertions)]
399        if tracing::enabled!(tracing::Level::TRACE) {
400            let formatted = format!(" - {msg}");
401            tracing::trace!("exec[{}]: {formatted}", self.id);
402            self.ops.push(formatted);
403        }
404        let _ = msg;
405    }
406}
407
408impl Display for ExecutionCtx {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        #[cfg(debug_assertions)]
411        return write!(f, "exec[{}]", self.id);
412        #[cfg(not(debug_assertions))]
413        write!(f, "exec")
414    }
415}
416
417#[cfg(debug_assertions)]
418impl Drop for ExecutionCtx {
419    fn drop(&mut self) {
420        if !self.ops.is_empty() && tracing::enabled!(tracing::Level::DEBUG) {
421            // Unlike itertools `.format()` (panics in 0.14 on second format)
422            struct FmtOps<'a>(&'a [String]);
423            impl Display for FmtOps<'_> {
424                fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425                    for (i, op) in self.0.iter().enumerate() {
426                        if i > 0 {
427                            f.write_str("\n")?;
428                        }
429                        f.write_str(op)?;
430                    }
431                    Ok(())
432                }
433            }
434            tracing::debug!("exec[{}] trace:\n{}", self.id, FmtOps(&self.ops));
435        }
436    }
437}
438
439/// Single-step execution: takes one step toward canonical form.
440///
441/// Steps through reduce, reduce_parent, execute_parent, then execute. For `ExecuteSlot`,
442/// only a single child execution step is performed — the child is executed once and put back,
443/// making this a lightweight, bounded operation.
444///
445/// **However**, if `execute_step` returns [`ExecutionStep::AppendChild`], this implementation
446/// drives the *entire* array to completion via [`execute_into_builder`] in a single call.
447/// This can do substantially more work than a normal step because it creates a builder and
448/// fully decodes the array into that builder before returning. Callers should be aware that a
449/// single `.execute::<ArrayRef>(ctx)` call may perform O(n_children * decode_cost) work when
450/// `AppendChild` is returned.
451impl Executable for ArrayRef {
452    fn execute(array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Self> {
453        trace_op!(record_single_step_start(&array));
454
455        if let Some(canonical) = array.as_opt::<AnyCanonical>() {
456            let output = Canonical::from(canonical).into_array();
457            trace_op!(record_single_step_applied("canonical", &array, &output));
458            return Ok(output);
459        }
460        trace_op!(record_single_step_phase_none("canonical", &array));
461
462        if let Some(reduced) = array.reduce()? {
463            reduced.statistics().inherit_from(array.statistics());
464            trace_op!(record_single_step_applied("reduce", &array, &reduced));
465            return Ok(reduced);
466        }
467        trace_op!(record_single_step_phase_none("reduce", &array));
468
469        for (slot_idx, slot) in array.slots().iter().enumerate() {
470            let Some(child) = slot else { continue };
471            if let Some(reduced_parent) = child.reduce_parent(&array, slot_idx)? {
472                reduced_parent.statistics().inherit_from(array.statistics());
473                trace_op!(record_single_step_applied(
474                    "reduce_parent",
475                    &array,
476                    &reduced_parent,
477                ));
478                return Ok(reduced_parent);
479            }
480        }
481        trace_op!(record_single_step_phase_none("reduce_parent", &array));
482
483        let execute_parent_kernels = Arc::clone(&ctx.execute_parent_kernels);
484        let kernels = execute_parent_kernels.as_ref();
485
486        for (slot_idx, slot) in array.slots().iter().enumerate() {
487            let Some(child) = slot else { continue };
488            if let Some(executed_parent) = execute_parent_for_child(
489                "single_step_execute_parent",
490                &array,
491                child,
492                slot_idx,
493                kernels,
494                ctx,
495            )? {
496                ctx.log(format_args!(
497                    "execute_parent: slot[{}]({}) rewrote {} -> {}",
498                    slot_idx,
499                    child.encoding_id(),
500                    array,
501                    executed_parent
502                ));
503                executed_parent
504                    .statistics()
505                    .inherit_from(array.statistics());
506                trace_op!(record_single_step_applied(
507                    "execute_parent",
508                    &array,
509                    &executed_parent,
510                ));
511                return Ok(executed_parent);
512            }
513        }
514        trace_op!(record_single_step_phase_none("execute_parent", &array));
515        trace_op!(record_execute_encoding(&array));
516
517        let result = array.execute_encoding(ctx)?;
518        let (array, step) = result.into_parts();
519        match step {
520            ExecutionStep::Done => {
521                trace_op!(record_execute_done(&array));
522                Ok(array)
523            }
524            ExecutionStep::ExecuteSlot(i, _) => {
525                let child = array.slots()[i].clone().vortex_expect("valid slot index");
526                let executed_child = child.execute::<ArrayRef>(ctx)?;
527                // SAFETY: execution of a child slot produces a logically equivalent array in a
528                // different physical representation, preserving parent values and statistics.
529                unsafe { array.with_slot(i, executed_child) }
530            }
531            ExecutionStep::AppendChild(_) => {
532                // Single-step: build the entire parent via the builder path.
533                trace_op!(record_builder_start(&array));
534                let builder = builder_with_capacity_in(ctx.allocator(), array.dtype(), array.len());
535                let mut builder = execute_into_builder(array, builder, ctx)?;
536                let output = builder.finish();
537                trace_op!(record_builder_finish(&output));
538                Ok(output)
539            }
540        }
541    }
542}
543
544/// Execute `array` into the given `builder`.
545///
546/// This uses the encoding's [`crate::array::VTable::append_to_builder`] implementation. Most
547/// encodings use the default path of `execute::<Canonical>` followed by re-dispatching
548/// `append_to_builder` on the canonical array, while encodings like `Chunked` can override that to
549/// append child-by-child without materializing the entire parent.
550///
551/// The builder must have a [`DType`] that is a nullability-superset of `array.dtype()`.
552pub fn execute_into_builder(
553    array: ArrayRef,
554    mut builder: Box<dyn ArrayBuilder>,
555    ctx: &mut ExecutionCtx,
556) -> VortexResult<Box<dyn ArrayBuilder>> {
557    array.append_to_builder(builder.as_mut(), ctx)?;
558    Ok(builder)
559}
560
561/// Pop a stack frame, restoring the parent with the finished child in its slot.
562fn pop_frame(
563    frame: StackFrame,
564    child: ArrayRef,
565) -> VortexResult<(ArrayRef, Option<Box<dyn ArrayBuilder>>)> {
566    debug_assert_eq!(
567        child.dtype(),
568        &frame.original_dtype,
569        "child dtype changed during execution"
570    );
571    debug_assert_eq!(
572        child.len(),
573        frame.original_len,
574        "child len changed during execution"
575    );
576    let parent_array = unsafe { frame.parent_array.put_slot_unchecked(frame.slot_idx, child) }?;
577    Ok((parent_array, frame.parent_builder))
578}
579
580fn finalize_done(
581    result: ArrayRef,
582    mut builder: Option<Box<dyn ArrayBuilder>>,
583    expected_len: usize,
584    expected_dtype: DType,
585    stats: ArrayStats,
586    encoding_id: ArrayId,
587) -> VortexResult<(ArrayRef, Option<Box<dyn ArrayBuilder>>)> {
588    let output = if let Some(mut builder) = builder.take() {
589        builder.finish()
590    } else {
591        result
592    };
593
594    if cfg!(debug_assertions) {
595        vortex_ensure!(
596            output.len() == expected_len,
597            "Result length mismatch for {:?}",
598            encoding_id
599        );
600        vortex_ensure!(
601            output.dtype() == &expected_dtype,
602            "Executed canonical dtype mismatch for {:?}",
603            encoding_id
604        );
605    }
606
607    output
608        .statistics()
609        .set_iter(StatsSet::from(stats).into_iter());
610    Ok((output, None))
611}
612
613fn execute_parent_for_child(
614    _phase: &'static str,
615    parent: &ArrayRef,
616    child: &ArrayRef,
617    slot_idx: usize,
618    kernels: &ParentExecutionKernels,
619    ctx: &mut ExecutionCtx,
620) -> VortexResult<Option<ArrayRef>> {
621    let key = execute_parent_key(parent.encoding_id(), child.encoding_id());
622    if let Some(plugins) = kernels.get(&key) {
623        #[allow(clippy::unused_enumerate_index)]
624        for (_plugin_idx, plugin) in plugins.as_ref().iter().enumerate() {
625            if let Some(result) = plugin.execute_parent(child, parent, slot_idx, ctx)? {
626                if cfg!(debug_assertions) {
627                    vortex_ensure!(
628                        result.len() == parent.len(),
629                        "Executed parent canonical length mismatch"
630                    );
631                    vortex_ensure!(
632                        result.dtype() == parent.dtype(),
633                        "Executed parent canonical dtype mismatch"
634                    );
635                }
636                trace_op!(record_session_execute_parent_applied(
637                    _phase,
638                    parent,
639                    child,
640                    slot_idx,
641                    _plugin_idx,
642                    &result,
643                ));
644                return Ok(Some(result));
645            }
646            trace_op!(record_session_execute_parent_declined(
647                _phase,
648                parent,
649                child,
650                slot_idx,
651                _plugin_idx,
652            ));
653        }
654    }
655
656    Ok(None)
657}
658
659/// Try execute_parent on each occupied slot of the array.
660fn try_execute_parent(
661    array: &ArrayRef,
662    kernels: &ParentExecutionKernels,
663    ctx: &mut ExecutionCtx,
664) -> VortexResult<Option<ArrayRef>> {
665    for (slot_idx, slot) in array.slots().iter().enumerate() {
666        let Some(child) = slot else { continue };
667        if let Some(executed_parent) =
668            execute_parent_for_child("child_execute_parent", array, child, slot_idx, kernels, ctx)?
669        {
670            ctx.log(format_args!(
671                "execute_parent: slot[{}]({}) rewrote {} -> {}",
672                slot_idx,
673                child.encoding_id(),
674                array,
675                executed_parent
676            ));
677            executed_parent
678                .statistics()
679                .inherit_from(array.statistics());
680            return Ok(Some(executed_parent));
681        }
682    }
683    Ok(None)
684}
685
686/// A predicate that determines when an array has reached a desired form during execution.
687pub type DonePredicate = fn(&ArrayRef) -> bool;
688
689/// Scheduler step indicator returned alongside an array in [`ExecutionResult`].
690///
691/// Instead of recursively executing children, encodings return an `ExecutionStep` that tells the
692/// scheduler what to do next. This enables the scheduler to manage execution iteratively using
693/// an explicit work stack plus an optional builder.
694///
695/// # Semantics
696///
697/// Each variant describes a different execution strategy with distinct cost profiles:
698///
699/// - [`Done`](ExecutionStep::Done): The current activation has finished its work. If no builder
700///   is active, the returned array is the result. If a builder is active, the scheduler ignores
701///   the placeholder array and finishes the builder instead. The scheduler may continue
702///   executing if the target form (e.g. canonical) has not yet been reached.
703///
704/// - [`ExecuteSlot`](ExecutionStep::ExecuteSlot): The encoding needs one of its children
705///   decoded before it can make further progress. The scheduler detaches that child, pushes
706///   the parent onto the explicit stack, executes the child until the [`DonePredicate`]
707///   matches, puts it back, and re-enters the parent. This is a cooperative yield: the
708///   encoding does a bounded amount of work per step while the loop tracks the parent-child
709///   relationship explicitly.
710///
711/// - [`AppendChild`](ExecutionStep::AppendChild): The encoding needs one child executed to
712///   canonical form and then appended into a builder owned by the current activation. The
713///   scheduler detaches that child, lazily creates `current_builder` if needed, appends the
714///   child into it, and keeps the parent as `current_array` for the next iteration. While the
715///   builder is active, parent-kernel rewrites are skipped because the parent is partially
716///   consumed. **Important:** in the single-step executor ([`Executable`] for [`ArrayRef`]),
717///   returning `AppendChild` still causes the executor to drive the *entire* array to
718///   completion via [`execute_into_builder`] in one call — this can do significantly more
719///   work than a single `ExecuteSlot` step.
720pub enum ExecutionStep {
721    /// Request that the scheduler execute the slot at the given index, using the provided
722    /// [`DonePredicate`] to determine when the slot is "done", then replace the slot in this
723    /// array and re-enter execution.
724    ///
725    /// Use [`ExecutionResult::execute_slot`] instead of constructing this variant directly.
726    ExecuteSlot(usize, DonePredicate),
727
728    /// Detach the slot at the given index, append that child into the current activation's
729    /// canonical builder, and keep the returned parent as `current_array`.
730    ///
731    /// `Done` finalizes that builder and turns it into the result of the activation.
732    ///
733    /// **Note:** In the single-step executor ([`Executable`] for [`ArrayRef`]), this variant
734    /// drives the entire parent to completion in one call via [`execute_into_builder`], which
735    /// may perform substantially more work than a single `ExecuteSlot` step.
736    AppendChild(usize),
737
738    /// Execution is complete. If no builder is active, the array in the accompanying
739    /// [`ExecutionResult`] is the result. Otherwise, the scheduler finalizes the active
740    /// builder and uses that finished array instead.
741    ///
742    /// The scheduler will continue executing if it has not yet reached the target form.
743    Done,
744}
745
746impl fmt::Debug for ExecutionStep {
747    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
748        match self {
749            ExecutionStep::ExecuteSlot(idx, _) => f.debug_tuple("ExecuteSlot").field(idx).finish(),
750            ExecutionStep::AppendChild(idx) => f.debug_tuple("AppendChild").field(idx).finish(),
751            ExecutionStep::Done => write!(f, "Done"),
752        }
753    }
754}
755
756/// The result of a single execution step on an array encoding.
757///
758/// Combines an [`ArrayRef`] with an [`ExecutionStep`] to tell the scheduler both what to do next
759/// and what array to work with.
760pub struct ExecutionResult {
761    array: ArrayRef,
762    step: ExecutionStep,
763}
764
765impl ExecutionResult {
766    /// Signal that execution is complete with the given result array.
767    pub fn done(result: impl IntoArray) -> Self {
768        Self {
769            array: result.into_array(),
770            step: ExecutionStep::Done,
771        }
772    }
773
774    /// Request execution of slot at `slot_idx` until it matches the given [`Matcher`].
775    ///
776    /// The provided array is the (possibly modified) parent that still needs its slot executed.
777    pub fn execute_slot<M: Matcher>(array: impl IntoArray, slot_idx: usize) -> Self {
778        let array = array.into_array();
779        Self {
780            array,
781            step: ExecutionStep::ExecuteSlot(slot_idx, M::matches),
782        }
783    }
784
785    /// Request that the child slot at `slot_idx` be detached, appended into the current
786    /// activation's canonical builder, and leave the returned parent as the next
787    /// `current_array`.
788    pub fn append_child(array: impl IntoArray, slot_idx: usize) -> Self {
789        let array = array.into_array();
790        Self {
791            array,
792            step: ExecutionStep::AppendChild(slot_idx),
793        }
794    }
795
796    /// Returns a reference to the array.
797    pub fn array(&self) -> &ArrayRef {
798        &self.array
799    }
800
801    /// Returns a reference to the step.
802    pub fn step(&self) -> &ExecutionStep {
803        &self.step
804    }
805
806    /// Decompose into parts.
807    pub fn into_parts(self) -> (ArrayRef, ExecutionStep) {
808        (self.array, self.step)
809    }
810}
811
812impl fmt::Debug for ExecutionResult {
813    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
814        f.debug_struct("ExecutionResult")
815            .field("array", &self.array)
816            .field("step", &self.step)
817            .finish()
818    }
819}
820
821/// Require that a child array matches `$M`. If the child already matches, returns the same
822/// array unchanged. Otherwise, early-returns an [`ExecutionResult`] requesting execution of
823/// child `$idx` until it matches `$M`.
824///
825/// ```ignore
826/// let array = require_child!(array, array.codes(), 0 => Primitive);
827/// let array = require_child!(array, array.values(), 1 => AnyCanonical);
828/// ```
829#[macro_export]
830macro_rules! require_child {
831    ($parent:expr, $child:expr, $idx:expr => $M:ty) => {{
832        if !$child.is::<$M>() {
833            return Ok($crate::ExecutionResult::execute_slot::<$M>(
834                $parent.clone(),
835                $idx,
836            ));
837        }
838        $parent
839    }};
840}
841
842/// Like [`require_child!`], but for optional children. If the child is `None`, this is a no-op.
843/// If the child is `Some` but does not match `$M`, early-returns an [`ExecutionResult`] requesting
844/// execution of child `$idx`.
845///
846/// Unlike `require_child!`, this is a statement macro (no value produced) and does not clone
847/// `$parent` - it is moved into the early-return path.
848///
849/// ```ignore
850/// require_opt_child!(array, array.patches().map(|p| p.indices()), 1 => Primitive);
851/// ```
852#[macro_export]
853macro_rules! require_opt_child {
854    ($parent:expr, $child_opt:expr, $idx:expr => $M:ty) => {
855        if $child_opt.is_some_and(|child| !child.is::<$M>()) {
856            return Ok($crate::ExecutionResult::execute_slot::<$M>($parent, $idx));
857        }
858    };
859}
860
861/// Require that patch slots (indices, values, and optionally chunk_offsets) are `Primitive`.
862/// If no patches are present (slots are `None`), this is a no-op.
863///
864/// Like [`require_opt_child!`], `$parent` is moved (not cloned) into the early-return path.
865///
866/// ```ignore
867/// require_patches!(
868///     array,
869///     MySlots::PATCH_INDICES,
870///     MySlots::PATCH_VALUES,
871///     MySlots::PATCH_CHUNK_OFFSETS
872/// );
873/// ```
874#[macro_export]
875macro_rules! require_patches {
876    ($parent:expr, $indices_slot:expr, $values_slot:expr, $chunk_offsets_slot:expr) => {
877        $crate::require_opt_child!(
878            $parent,
879            $parent.slots()[$indices_slot].as_ref(),
880            $indices_slot => $crate::arrays::Primitive
881        );
882        $crate::require_opt_child!(
883            $parent,
884            $parent.slots()[$values_slot].as_ref(),
885            $values_slot => $crate::arrays::Primitive
886        );
887        $crate::require_opt_child!(
888            $parent,
889            $parent.slots()[$chunk_offsets_slot].as_ref(),
890            $chunk_offsets_slot => $crate::arrays::Primitive
891        );
892    };
893}
894
895/// Require that the validity slot is a [`Bool`](crate::arrays::Bool) array. If validity is not
896/// array-backed (e.g. `NonNullable` or `AllValid`), this is a no-op. If it is array-backed but
897/// not `Bool`, early-returns an [`ExecutionResult`] requesting execution of the validity slot.
898///
899/// Like [`require_opt_child!`], `$parent` is moved (not cloned) into the early-return path.
900///
901/// ```ignore
902/// require_validity!(array, MySlots::VALIDITY);
903/// ```
904#[macro_export]
905macro_rules! require_validity {
906    ($parent:expr, $idx:expr) => {
907        $crate::require_opt_child!(
908            $parent,
909            $parent.slots()[$idx].as_ref(),
910            $idx => $crate::arrays::Bool
911        );
912    };
913}
914
915/// Extension trait for creating an execution context from a session.
916pub trait VortexSessionExecute {
917    /// Create a new execution context from this session.
918    fn create_execution_ctx(&self) -> ExecutionCtx;
919}
920
921impl VortexSessionExecute for VortexSession {
922    fn create_execution_ctx(&self) -> ExecutionCtx {
923        ExecutionCtx::new(self.clone())
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use vortex_session::VortexSession;
930
931    use super::*;
932    use crate::VTable as _;
933    use crate::VortexSessionExecute;
934    use crate::arrays::Bool;
935    use crate::arrays::Primitive;
936    use crate::optimizer::kernels::ExecuteParentFn;
937    use crate::optimizer::kernels::KernelSession;
938    use crate::optimizer::kernels::execute_parent_key;
939
940    fn noop_execute_parent(
941        _child: &ArrayRef,
942        _parent: &ArrayRef,
943        _child_idx: usize,
944        _ctx: &mut ExecutionCtx,
945    ) -> VortexResult<Option<ArrayRef>> {
946        Ok(None)
947    }
948
949    #[test]
950    fn execution_ctx_snapshots_execute_parent_kernels_at_creation() {
951        let session = VortexSession::empty().with_some(KernelSession::empty());
952        let key = execute_parent_key(Bool.id(), Primitive.id());
953
954        let before_registration = session.create_execution_ctx();
955        assert!(
956            !before_registration
957                .execute_parent_kernels
958                .contains_key(&key)
959        );
960
961        let kernels = session.kernels();
962        kernels.register_execute_parent(
963            Bool.id(),
964            Primitive.id(),
965            &[noop_execute_parent as ExecuteParentFn],
966        );
967
968        assert!(
969            !before_registration
970                .execute_parent_kernels
971                .contains_key(&key)
972        );
973
974        let after_registration = session.create_execution_ctx();
975        assert!(after_registration.execute_parent_kernels.contains_key(&key));
976    }
977}