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>(¤t_array));
177
178 for _iteration in 0..max_iterations {
179 trace_op!(record_execute_until_iteration(
180 _iteration,
181 ¤t_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(¤t_array);
193 let done_canonical = AnyCanonical::matches(¤t_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(¤t_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, ¤t_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 ¤t_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 ¤t_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(¤t_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 ¤t_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(¤t_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(¤t_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}