Skip to main content

xlog_runtime/executor/
node_dispatch.rs

1//! RIR node dispatch and per-node execution handlers.
2
3use std::collections::HashMap;
4
5use xlog_core::{AggOp, RelId, Result, ScalarType, Schema, XlogError};
6use xlog_cuda::provider::NESTED_LOOP_TOTAL_THRESHOLD;
7use xlog_cuda::{CudaBuffer, JoinType as CudaJoinType};
8use xlog_ir::{JoinType, ProjectExpr, RirNode};
9
10use crate::ilp_registry::{read_device_row_count, IlpMask, IlpTagEntry, IlpTaggedResult};
11
12use super::join_cache::{estimate_join_index_bytes, JoinIndexKey};
13use super::Executor;
14
15/// Eligibility predicate for nested-loop join dispatch.
16///
17/// Returns `true` iff the join shape is admissible for the
18/// `nested_loop_join_v2_inner_u32_1key` provider entry point.
19/// The predicate is intentionally narrow for the nested-loop dispatch contract:
20///   * `JoinType::Inner` only (Semi / Anti / LeftOuter fall back
21///     to hash).
22///   * Exactly one key column on each side.
23///   * Both key columns share the same `ScalarType` AND that
24///     shared type is `U32` or `Symbol` (Symbol is `u32` at the
25///     byte level — same kernel applies). U32-on-Symbol or
26///     other type mismatches return `false`, mirroring
27///     `hash_join_v2`'s own type-mismatch rejection at
28///     `crates/xlog-cuda/src/provider/relational.rs:3567-3576`.
29///
30/// Out-of-bounds key indices yield `Schema::column_type(_) = None`,
31/// which fails the `matches!(...)` guard — falling back to hash
32/// without a separate bounds check.
33///
34/// Cheap O(1): no kernel launches, no row-count reads, no device-to-host transfer.
35/// The threshold check (`num_left * num_right <=
36/// NESTED_LOOP_TOTAL_THRESHOLD`) is performed at the dispatch
37/// site, not in this predicate.
38//
39fn eligible_for_nested_loop(
40    left: &CudaBuffer,
41    right: &CudaBuffer,
42    left_keys: &[usize],
43    right_keys: &[usize],
44    join_type: JoinType,
45) -> bool {
46    if join_type != JoinType::Inner {
47        return false;
48    }
49    if left_keys.len() != 1 || right_keys.len() != 1 {
50        return false;
51    }
52    let lt = left.schema().column_type(left_keys[0]);
53    let rt = right.schema().column_type(right_keys[0]);
54    lt == rt && matches!(lt, Some(ScalarType::U32) | Some(ScalarType::Symbol))
55}
56
57fn is_join_index_mismatch(err: &XlogError) -> bool {
58    matches!(
59        err,
60        XlogError::Kernel(msg)
61            if msg.contains("Join index row count does not match right relation")
62                || msg.contains("Join index key columns do not match requested right_keys")
63    )
64}
65
66impl Executor {
67    /// Execute a Scan node — looks up the relation by RelId and returns a clone.
68    pub(super) fn execute_scan(&mut self, rel: RelId) -> Result<CudaBuffer> {
69        let name = self
70            .get_rel_name(rel)
71            .ok_or_else(|| XlogError::Execution(format!("Unknown relation: RelId({})", rel.0)))?;
72
73        let buffer = self
74            .store
75            .get(name)
76            .ok_or_else(|| XlogError::Execution(format!("Relation not found: {}", name)))?;
77
78        self.stats.record_access(rel);
79        self.stats.update_cardinality(rel, buffer.num_rows());
80        self.stats.update_byte_size(rel, buffer.estimated_bytes());
81
82        self.clone_buffer(buffer)
83    }
84
85    /// Execute a single RIR node tree
86    ///
87    /// Recursively evaluates the node and its children, returning
88    /// the result as a GPU buffer.
89    ///
90    /// # Arguments
91    /// * `node` - The RIR node to execute
92    ///
93    /// # Returns
94    /// A CudaBuffer containing the result of the node execution
95    ///
96    /// # Errors
97    /// Returns an error if the node execution fails
98    pub fn execute_node(&mut self, node: &RirNode) -> Result<CudaBuffer> {
99        if !self.common_subexpression_enabled() || !Self::is_common_subexpression_cacheable(node) {
100            return self.execute_node_uncached(node);
101        }
102
103        let Some(key) = self.common_subexpression_key(node) else {
104            return self.execute_node_uncached(node);
105        };
106
107        if self.common_subexpression_cache.contains_key(&key) {
108            let cached = self
109                .common_subexpression_cache
110                .remove(&key)
111                .expect("cache key checked above");
112            let result = self.clone_buffer(&cached)?;
113            self.common_subexpression_cache.insert(key, cached);
114            self.common_subexpression_stats.hits =
115                self.common_subexpression_stats.hits.saturating_add(1);
116            return Ok(result);
117        }
118
119        self.common_subexpression_stats.misses =
120            self.common_subexpression_stats.misses.saturating_add(1);
121        let result = self.execute_node_uncached(node)?;
122        let cached = self.clone_buffer(&result)?;
123        self.common_subexpression_cache.insert(key, cached);
124        Ok(result)
125    }
126
127    fn execute_node_uncached(&mut self, node: &RirNode) -> Result<CudaBuffer> {
128        match node {
129            RirNode::Unit => {
130                // Materialize the relational "unit" ({()}) as a 0-arity buffer with one row.
131                let mut d_num_rows = self.provider.memory().alloc::<u32>(1)?;
132                self.provider
133                    .htod_launch_metadata_sync_copy_into(&[1u32], &mut d_num_rows)
134                    .map_err(|e| {
135                        XlogError::Kernel(format!("Failed to create unit row count: {}", e))
136                    })?;
137                Ok(CudaBuffer::from_columns(
138                    Vec::new(),
139                    1,
140                    d_num_rows,
141                    Schema::new(vec![]),
142                ))
143            }
144
145            RirNode::Scan { rel } => {
146                let start = self.profiler.start_op();
147                let result = self.execute_scan(*rel)?;
148                if let Some(start) = start {
149                    let mem = self.provider.memory().allocated_bytes();
150                    self.profiler
151                        .record_op("scan", 0, result.num_rows(), start, mem);
152                    self.profiler.record_peak_memory(mem);
153                }
154                Ok(result)
155            }
156
157            RirNode::Filter { input, predicate } => {
158                let input_buf = self.execute_node(input)?;
159                let input_rows = input_buf.num_rows();
160                let start = self.profiler.start_op();
161                let result = self.execute_filter(&input_buf, predicate)?;
162                if let Some(start) = start {
163                    let mem = self.provider.memory().allocated_bytes();
164                    self.profiler
165                        .record_op("filter", input_rows, result.num_rows(), start, mem);
166                    self.profiler.record_peak_memory(mem);
167                }
168                Ok(result)
169            }
170
171            RirNode::Project { .. } => self.execute_project_chain(node),
172
173            RirNode::Join {
174                left,
175                right,
176                left_keys,
177                right_keys,
178                join_type,
179            } => {
180                let left_rel = match left.as_ref() {
181                    RirNode::Scan { rel } => Some(*rel),
182                    _ => None,
183                };
184                let right_rel = match right.as_ref() {
185                    RirNode::Scan { rel } => Some(*rel),
186                    _ => None,
187                };
188                let left_buf = self.execute_node(left)?;
189                let right_buf = self.execute_node(right)?;
190                let input_rows = left_buf.num_rows() + right_buf.num_rows();
191                let start = self.profiler.start_op();
192                let result = self.execute_join(
193                    &left_buf, &right_buf, left_keys, right_keys, *join_type, left_rel, right_rel,
194                )?;
195                if let Some(start) = start {
196                    let mem = self.provider.memory().allocated_bytes();
197                    self.profiler
198                        .record_op("join", input_rows, result.num_rows(), start, mem);
199                    self.profiler.record_peak_memory(mem);
200                }
201                Ok(result)
202            }
203
204            RirNode::GroupBy {
205                input,
206                key_cols,
207                aggs,
208            } => {
209                // Aggregate fusion: a count/sum/min/max-by-root over a
210                // promoted triangle dispatches the fused kernels and never
211                // materializes the join. Declines fall through to the
212                // standard materialize+groupby path below.
213                if let Some(fused) =
214                    self.try_dispatch_wcoj_groupby_root_agg(input, key_cols, aggs)?
215                {
216                    return Ok(fused);
217                }
218                let input_buf = self.execute_node(input)?;
219                let input_rows = input_buf.num_rows();
220                let start = self.profiler.start_op();
221                let result = self.execute_groupby(&input_buf, key_cols, aggs)?;
222                if let Some(start) = start {
223                    let mem = self.provider.memory().allocated_bytes();
224                    self.profiler
225                        .record_op("groupby", input_rows, result.num_rows(), start, mem);
226                    self.profiler.record_peak_memory(mem);
227                }
228                Ok(result)
229            }
230
231            RirNode::Union { inputs } => {
232                let mut buffers = Vec::with_capacity(inputs.len());
233                let mut input_rows = 0u64;
234                for input in inputs {
235                    let buf = self.execute_node(input)?;
236                    input_rows += buf.num_rows();
237                    buffers.push(buf);
238                }
239                let start = self.profiler.start_op();
240                let result = self.execute_union(&buffers)?;
241                if let Some(start) = start {
242                    let mem = self.provider.memory().allocated_bytes();
243                    self.profiler
244                        .record_op("union", input_rows, result.num_rows(), start, mem);
245                    self.profiler.record_peak_memory(mem);
246                }
247                Ok(result)
248            }
249
250            RirNode::Distinct { input, key_cols } => {
251                let input_buf = self.execute_node(input)?;
252                let input_rows = input_buf.num_rows();
253                let start = self.profiler.start_op();
254                let result = self.execute_distinct(&input_buf, key_cols)?;
255                if let Some(start) = start {
256                    let mem = self.provider.memory().allocated_bytes();
257                    self.profiler
258                        .record_op("dedup", input_rows, result.num_rows(), start, mem);
259                    self.profiler.record_peak_memory(mem);
260                }
261                Ok(result)
262            }
263
264            RirNode::Diff { left, right } => {
265                let left_buf = self.execute_node(left)?;
266                let right_buf = self.execute_node(right)?;
267                let input_rows = left_buf.num_rows() + right_buf.num_rows();
268                let start = self.profiler.start_op();
269                let result = self.execute_diff(&left_buf, &right_buf)?;
270                if let Some(start) = start {
271                    let mem = self.provider.memory().allocated_bytes();
272                    self.profiler
273                        .record_op("diff", input_rows, result.num_rows(), start, mem);
274                    self.profiler.record_peak_memory(mem);
275                }
276                Ok(result)
277            }
278
279            RirNode::Fixpoint {
280                scc_id,
281                base,
282                recursive,
283                delta_rel,
284                full_rel,
285            } => {
286                // Semi-naive fixpoint iteration
287                self.execute_fixpoint(*scc_id, base, recursive, *delta_rel, *full_rel)
288            }
289            RirNode::TensorMaskedJoin {
290                mask_name,
291                schema_size,
292                left_keys,
293                right_keys,
294                rel_index,
295                head_rel_name,
296                max_active_rules,
297                head_projection,
298                ..
299            } => self.execute_tensor_masked_join(
300                mask_name,
301                *schema_size,
302                left_keys,
303                right_keys,
304                rel_index,
305                head_rel_name,
306                *max_active_rules,
307                head_projection,
308            ),
309            // Defensive fallback descent for any
310            // `execute_node` caller that bypasses the WCOJ dispatch
311            // hook (probabilistic eval, neural store walks, etc.).
312            // The non-recursive arm in `recursive.rs` short-circuits
313            // dispatch-eligible bodies before reaching here; this
314            // arm is the safety net for everyone else.
315            RirNode::MultiWayJoin { fallback, .. } | RirNode::ChainJoin { fallback, .. } => {
316                self.execute_node(fallback)
317            }
318        }
319    }
320
321    fn project_chain_parts(node: &RirNode) -> (&RirNode, Vec<&[ProjectExpr]>) {
322        let mut projections = Vec::new();
323        let mut base = node;
324        while let RirNode::Project { input, columns } = base {
325            projections.push(columns.as_slice());
326            base = input;
327        }
328        projections.reverse();
329        (base, projections)
330    }
331
332    fn execute_project_chain(&mut self, node: &RirNode) -> Result<CudaBuffer> {
333        let (base, projections) = Self::project_chain_parts(node);
334        let mut result = self.execute_node(base)?;
335        for columns in projections {
336            let input = result;
337            let input_rows = input.num_rows();
338            let start = self.profiler.start_op();
339            let projected = self.execute_project(&input, columns)?;
340            if let Some(start) = start {
341                let mem = self.provider.memory().allocated_bytes();
342                self.profiler
343                    .record_op("project", input_rows, projected.num_rows(), start, mem);
344                self.profiler.record_peak_memory(mem);
345            }
346            result = projected;
347        }
348        Ok(result)
349    }
350
351    /// Execute a Join node
352    ///
353    /// Delegates to the kernel provider's hash_join_v2 which supports all join types natively.
354    #[allow(clippy::too_many_arguments)]
355    fn execute_join(
356        &mut self,
357        left: &CudaBuffer,
358        right: &CudaBuffer,
359        left_keys: &[usize],
360        right_keys: &[usize],
361        join_type: JoinType,
362        left_rel: Option<RelId>,
363        right_rel: Option<RelId>,
364    ) -> Result<CudaBuffer> {
365        // Convert IR JoinType to CUDA JoinType (used by adaptive
366        // indexing and the hash fallback below).
367        let cuda_join_type = match join_type {
368            JoinType::Inner => CudaJoinType::Inner,
369            JoinType::Semi => CudaJoinType::Semi,
370            JoinType::Anti => CudaJoinType::Anti,
371            JoinType::LeftOuter => CudaJoinType::LeftOuter,
372        };
373
374        // Output buffer set by nested-loop dispatch,
375        // adaptive indexing, or the hash fallback. All three
376        // paths flow through the shared `record_join_result`
377        // feedback block at the end of this fn.
378        let mut out: Option<CudaBuffer> = None;
379
380        // Nested-loop dispatch precedes adaptive indexing
381        // and hash fallback. On predicate + threshold pass,
382        // route to `nested_loop_join_v2_inner_u32_1key` and
383        // bump the dispatch counter; do NOT early-return —
384        // leave the result in `out` so the shared feedback
385        // block observes it. Otherwise leave `out` unchanged.
386        //
387        // Threshold check uses logical row counts via
388        // `provider.device_row_count(...)` (NOT `row_cap`), with
389        // `checked_mul` fail-closed on overflow before comparing
390        // against the nested-loop total-row threshold.
391        if eligible_for_nested_loop(left, right, left_keys, right_keys, join_type) {
392            let num_left = self.provider.device_row_count(left)? as u64;
393            let num_right = self.provider.device_row_count(right)? as u64;
394            let in_threshold = num_left
395                .checked_mul(num_right)
396                .map(|p| p <= NESTED_LOOP_TOTAL_THRESHOLD)
397                .unwrap_or(false);
398            if in_threshold {
399                out = Some(self.provider.nested_loop_join_v2_inner_u32_1key(
400                    left,
401                    right,
402                    left_keys[0],
403                    right_keys[0],
404                )?);
405                self.nested_loop_dispatch_count += 1;
406            }
407        }
408
409        // Adaptive indexing: opportunistically reuse cached
410        // build-side hash tables when the right side is a base
411        // relation scan and has become "hot" in runtime
412        // statistics. Only runs if nested-loop dispatch did not
413        // dispatch.
414        if out.is_none() && self.config.resolved_persistent_hash_indexes() {
415            if let Some(build_rel) = right_rel {
416                let build_heat = self
417                    .stats
418                    .get_relation_stats(build_rel)
419                    .map(|s| s.heat)
420                    .unwrap_or(0.0);
421                let est_index_bytes = estimate_join_index_bytes(right, right_keys);
422                let budget_bytes = self.provider.memory().budget().device_bytes;
423                let remaining_bytes = self.provider.memory().remaining_bytes();
424
425                let should_index = self.join_index_cache.should_build(
426                    est_index_bytes,
427                    build_heat,
428                    remaining_bytes,
429                    budget_bytes,
430                );
431
432                if let Some(build_name) = self.get_rel_name(build_rel).map(|s| s.to_string()) {
433                    if let Some(version) = self.store.version(&build_name) {
434                        let key = JoinIndexKey::new(
435                            build_rel,
436                            version,
437                            right_keys.to_vec(),
438                            right.schema(),
439                            self.provider.device().ordinal() as u32,
440                        );
441
442                        let indexed_result = {
443                            self.join_index_cache.get(&key).map(|index| {
444                                self.provider.hash_join_v2_with_index(
445                                    left,
446                                    right,
447                                    left_keys,
448                                    right_keys,
449                                    cuda_join_type,
450                                    index,
451                                    None,
452                                )
453                            })
454                        };
455                        if let Some(indexed_result) = indexed_result {
456                            match indexed_result {
457                                Ok(joined) => out = Some(joined),
458                                Err(err) if is_join_index_mismatch(&err) => {
459                                    self.join_index_cache.remove_stale(&key);
460                                }
461                                Err(err) => return Err(err),
462                            }
463                        } else if should_index {
464                            let background_build = self
465                                .config
466                                .resolved_persistent_hash_index_background_build();
467                            if background_build {
468                                self.join_index_cache.record_background_build_request();
469                            }
470                            let build_result = if background_build {
471                                self.provider
472                                    .build_join_index_v2_background(right, right_keys)
473                            } else {
474                                self.provider.build_join_index_v2(right, right_keys)
475                            };
476                            match build_result {
477                                Ok(index) => {
478                                    if background_build {
479                                        self.join_index_cache.record_background_build_complete();
480                                        self.join_index_cache.insert(key, index);
481                                        self.join_index_cache.record_background_build_deferred();
482                                        if let Some(stats) =
483                                            self.stats.get_relation_stats_mut(build_rel)
484                                        {
485                                            stats.has_index = true;
486                                        }
487                                    } else {
488                                        match self.provider.hash_join_v2_with_index(
489                                            left,
490                                            right,
491                                            left_keys,
492                                            right_keys,
493                                            cuda_join_type,
494                                            &index,
495                                            None,
496                                        ) {
497                                            Ok(joined) => {
498                                                self.join_index_cache.insert(key, index);
499                                                if let Some(stats) =
500                                                    self.stats.get_relation_stats_mut(build_rel)
501                                                {
502                                                    stats.has_index = true;
503                                                }
504                                                out = Some(joined);
505                                            }
506                                            Err(err) if is_join_index_mismatch(&err) => {}
507                                            Err(err) => return Err(err),
508                                        }
509                                    }
510                                }
511                                Err(_) => {
512                                    // If indexing fails (e.g., memory pressure), fall back to normal join.
513                                }
514                            }
515                        }
516                    }
517                }
518            }
519        } // end adaptive-indexing gate
520
521        let out = match out {
522            Some(buf) => buf,
523            None => {
524                self.provider
525                    .hash_join_v2(left, right, left_keys, right_keys, cuda_join_type)?
526            }
527        };
528
529        if let (Some(l), Some(r)) = (left_rel, right_rel) {
530            let input_rows = left.num_rows().saturating_mul(right.num_rows());
531            self.record_adaptive_join_observation(
532                l,
533                r,
534                left_keys,
535                right_keys,
536                input_rows,
537                out.num_rows(),
538            );
539            self.stats.record_join_result(
540                l,
541                r,
542                left_keys.to_vec(),
543                right_keys.to_vec(),
544                input_rows,
545                out.num_rows(),
546            );
547        }
548
549        Ok(out)
550    }
551
552    /// Execute a GroupBy node
553    ///
554    /// Delegates to the kernel provider's groupby_multi_agg for multi-aggregation support.
555    fn execute_groupby(
556        &self,
557        input: &CudaBuffer,
558        key_cols: &[usize],
559        aggs: &[(usize, AggOp)],
560    ) -> Result<CudaBuffer> {
561        if aggs.is_empty() {
562            // No aggregations: just distinct on key columns
563            return self.provider.dedup(input, key_cols);
564        }
565
566        // Use multi-aggregation groupby
567        self.provider.groupby_multi_agg(input, key_cols, aggs)
568    }
569
570    /// Execute a Union node
571    ///
572    /// Combines multiple input buffers into one using GPU-native operation.
573    pub(super) fn execute_union(&self, inputs: &[CudaBuffer]) -> Result<CudaBuffer> {
574        if inputs.is_empty() {
575            return self.provider.create_empty_buffer(Schema::new(vec![]));
576        }
577
578        if inputs.len() == 1 {
579            return self.clone_buffer(&inputs[0]);
580        }
581
582        // Multiway union: one concat + sort + dedup over all inputs instead
583        // of re-sorting a growing accumulator per input.
584        let input_refs: Vec<&CudaBuffer> = inputs.iter().collect();
585        self.provider.union_many_gpu(&input_refs)
586    }
587
588    /// Execute a Distinct node
589    ///
590    /// Removes duplicate rows based on key columns.
591    pub(super) fn execute_distinct(
592        &self,
593        input: &CudaBuffer,
594        key_cols: &[usize],
595    ) -> Result<CudaBuffer> {
596        self.provider.dedup(input, key_cols)
597    }
598
599    /// Execute a Diff node
600    ///
601    /// Returns rows in left that are not in right using GPU-native operation.
602    pub(super) fn execute_diff(&self, left: &CudaBuffer, right: &CudaBuffer) -> Result<CudaBuffer> {
603        self.provider.diff_gpu(left, right)
604    }
605
606    #[allow(clippy::too_many_arguments)]
607    fn execute_tensor_masked_join(
608        &mut self,
609        mask_name: &str,
610        schema_size: usize,
611        left_keys: &[usize],
612        right_keys: &[usize],
613        rel_index: &[(RelId, String)],
614        head_rel_name: &str,
615        max_active_rules: usize,
616        head_projection: &[usize],
617    ) -> Result<CudaBuffer> {
618        // No-op when no mask is registered. Return an empty buffer with
619        // the head relation's schema (not Schema::new(vec![])) to prevent
620        // schema corruption when execute_non_recursive_scc stores the result.
621        let ilp_mask = match self.ilp_registry.get_mask(mask_name) {
622            Some(mask) => mask,
623            None => {
624                self.ilp_last_result = Some(IlpTaggedResult {
625                    entries: Vec::new(),
626                });
627                // Fail hard if the head relation is missing from the store.
628                let schema = self
629                    .store
630                    .get(head_rel_name)
631                    .map(|buf| buf.schema().clone())
632                    .ok_or_else(|| {
633                        XlogError::Execution(format!(
634                            "TensorMaskedJoin: head relation '{}' not found in store \
635                         (was load_facts_into_store called?)",
636                            head_rel_name
637                        ))
638                    })?;
639                return self.provider.create_empty_buffer(schema);
640            }
641        };
642
643        let start = self.profiler.start_op();
644
645        let head_k = rel_index
646            .iter()
647            .position(|(_, name)| name == head_rel_name)
648            .ok_or_else(|| {
649                XlogError::Execution(format!(
650                    "TensorMaskedJoin: head relation '{}' not found in rel_index",
651                    head_rel_name
652                ))
653            })? as u32;
654
655        let mut tag_entries: Vec<IlpTagEntry> = Vec::new();
656        let mut process_rule = |i: u32,
657                                j: u32,
658                                k: u32,
659                                strict_candidate_idx: Option<usize>,
660                                strict_flags: Option<&CudaBuffer>|
661         -> Result<()> {
662            if k != head_k {
663                return Ok(());
664            }
665
666            let (_, left_name) = &rel_index[i as usize];
667            let (_, right_name) = &rel_index[j as usize];
668
669            let left_buf = match self.store.get(left_name) {
670                Some(buf) if buf.arity() > 0 => buf,
671                _ => return Ok(()),
672            };
673            let right_buf = match self.store.get(right_name) {
674                Some(buf) if buf.arity() > 0 => buf,
675                _ => return Ok(()),
676            };
677
678            // Skip arity-mismatched relations: the join keys are fixed by
679            // the learnable rule template, so the mapped relation must have
680            // enough columns for every key index. Relations with matching
681            // arity but different semantic column meanings will join without
682            // error; semantic correctness of the mask is the optimizer's
683            // responsibility.
684            let left_max_key = left_keys.iter().copied().max().unwrap_or(0);
685            let right_max_key = right_keys.iter().copied().max().unwrap_or(0);
686            if left_buf.arity() <= left_max_key || right_buf.arity() <= right_max_key {
687                return Ok(());
688            }
689
690            let joined = self.provider.hash_join_v2(
691                left_buf,
692                right_buf,
693                left_keys,
694                right_keys,
695                CudaJoinType::Inner,
696            )?;
697
698            // Project join result to head schema columns if projection is specified.
699            // The join produces [left_cols..., right_cols...] but the head may only
700            // need a subset (e.g. reach(X,Y) from b1(X,Z) join b2(Z,Y) needs cols 0,3).
701            let projected = if !head_projection.is_empty() && head_projection.len() < joined.arity()
702            {
703                let proj_exprs: Vec<ProjectExpr> = head_projection
704                    .iter()
705                    .map(|&col| ProjectExpr::Column(col))
706                    .collect();
707                self.execute_project(&joined, &proj_exprs)?
708            } else {
709                joined
710            };
711
712            let projected = if let (Some(candidate_idx), Some(active_flags)) =
713                (strict_candidate_idx, strict_flags)
714            {
715                self.provider.filter_buffer_by_candidate_flag(
716                    &projected,
717                    active_flags,
718                    candidate_idx,
719                )?
720            } else {
721                projected
722            };
723
724            // Use the public helper instead of the private device_row_count.
725            let num_rows = read_device_row_count(&self.provider, &projected)? as u32;
726
727            if num_rows > 0 {
728                tag_entries.push(IlpTagEntry {
729                    i,
730                    j,
731                    k,
732                    num_rows,
733                    buffer: Some(projected),
734                });
735            }
736            Ok(())
737        };
738
739        let active_rule_count = match ilp_mask {
740            IlpMask::Dense { hard, soft, .. } => {
741                let active_rules = self.provider.extract_active_rule_indices(
742                    hard,
743                    soft,
744                    schema_size,
745                    max_active_rules,
746                )?;
747                let count = active_rules.len() as u64;
748                for &(i, j, k) in &active_rules {
749                    process_rule(i, j, k, None, None)?;
750                }
751                count
752            }
753            IlpMask::Sparse { active_entries, .. } => {
754                let limit = max_active_rules.min(active_entries.len());
755                for &(i, j, k) in &active_entries[..limit] {
756                    process_rule(i, j, k, None, None)?;
757                }
758                limit as u64
759            }
760            IlpMask::SparseDevice {
761                candidate_order,
762                active_flags,
763                selected_count,
764                ..
765            } => {
766                if *selected_count > 0 {
767                    for (candidate_idx, &(i, j, k)) in candidate_order.iter().enumerate() {
768                        process_rule(i, j, k, Some(candidate_idx), Some(active_flags))?;
769                    }
770                }
771                (*selected_count).min(max_active_rules) as u64
772            }
773        };
774
775        // Union per-rule results by head relation index, borrowing buffers from tag_entries.
776        let mut bufs_by_k: HashMap<u32, Vec<&CudaBuffer>> = HashMap::new();
777        for entry in &tag_entries {
778            if let Some(ref buf) = entry.buffer {
779                bufs_by_k.entry(entry.k).or_default().push(buf);
780            }
781        }
782
783        for (k, buffers) in bufs_by_k {
784            let (_, target_name) = &rel_index[k as usize];
785
786            // One multiway union per head instead of one union per buffer:
787            // re-sorting a growing accumulator per rule goes quadratic in
788            // rules per head. A single buffer deduplicates the same way the
789            // old union-with-empty did.
790            let union_buf = self.provider.union_many_gpu(&buffers)?;
791
792            // Diff against existing and merge
793            if let Some(existing) = self.store.get(target_name) {
794                let delta = self.provider.diff_gpu(&union_buf, existing)?;
795                if !delta.is_empty() {
796                    let merged = self.provider.union_gpu(existing, &delta)?;
797                    self.store_put(target_name, merged);
798                }
799            } else {
800                let key_cols: Vec<usize> = (0..union_buf.arity()).collect();
801                let deduped = self.provider.dedup(&union_buf, &key_cols)?;
802                self.store_put(target_name, deduped);
803            }
804        }
805
806        // Store tag entries with retained buffers.
807        self.ilp_last_result = Some(IlpTaggedResult {
808            entries: tag_entries,
809        });
810
811        if let Some(start) = start {
812            let mem = self.provider.memory().allocated_bytes();
813            self.profiler
814                .record_op("TensorMaskedJoin", 0, active_rule_count, start, mem);
815        }
816
817        // Return empty with head schema (results routed via store).
818        let schema = self
819            .store
820            .get(head_rel_name)
821            .map(|buf| buf.schema().clone())
822            .ok_or_else(|| {
823                XlogError::Execution(format!(
824                    "TensorMaskedJoin: head relation '{}' not found in store \
825                 (was load_facts_into_store called?)",
826                    head_rel_name
827                ))
828            })?;
829        self.provider.create_empty_buffer(schema)
830    }
831}