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                self.record_wcoj_groupby_fallback(input);
219                let input_buf = self.execute_node(input)?;
220                let input_rows = input_buf.num_rows();
221                let start = self.profiler.start_op();
222                let result = self.execute_groupby(&input_buf, key_cols, aggs)?;
223                if let Some(start) = start {
224                    let mem = self.provider.memory().allocated_bytes();
225                    self.profiler
226                        .record_op("groupby", input_rows, result.num_rows(), start, mem);
227                    self.profiler.record_peak_memory(mem);
228                }
229                Ok(result)
230            }
231
232            RirNode::Union { inputs } => {
233                let mut buffers = Vec::with_capacity(inputs.len());
234                let mut input_rows = 0u64;
235                for input in inputs {
236                    let buf = self.execute_node(input)?;
237                    input_rows += buf.num_rows();
238                    buffers.push(buf);
239                }
240                let start = self.profiler.start_op();
241                let result = self.execute_union(&buffers)?;
242                if let Some(start) = start {
243                    let mem = self.provider.memory().allocated_bytes();
244                    self.profiler
245                        .record_op("union", input_rows, result.num_rows(), start, mem);
246                    self.profiler.record_peak_memory(mem);
247                }
248                Ok(result)
249            }
250
251            RirNode::Distinct { input, key_cols } => {
252                let input_buf = self.execute_node(input)?;
253                let input_rows = input_buf.num_rows();
254                let start = self.profiler.start_op();
255                let result = self.execute_distinct(&input_buf, key_cols)?;
256                if let Some(start) = start {
257                    let mem = self.provider.memory().allocated_bytes();
258                    self.profiler
259                        .record_op("dedup", input_rows, result.num_rows(), start, mem);
260                    self.profiler.record_peak_memory(mem);
261                }
262                Ok(result)
263            }
264
265            RirNode::Diff { left, right } => {
266                let left_buf = self.execute_node(left)?;
267                let right_buf = self.execute_node(right)?;
268                let input_rows = left_buf.num_rows() + right_buf.num_rows();
269                let start = self.profiler.start_op();
270                let result = self.execute_diff(&left_buf, &right_buf)?;
271                if let Some(start) = start {
272                    let mem = self.provider.memory().allocated_bytes();
273                    self.profiler
274                        .record_op("diff", input_rows, result.num_rows(), start, mem);
275                    self.profiler.record_peak_memory(mem);
276                }
277                Ok(result)
278            }
279
280            RirNode::Fixpoint {
281                scc_id,
282                base,
283                recursive,
284                delta_rel,
285                full_rel,
286            } => {
287                // Semi-naive fixpoint iteration
288                self.execute_fixpoint(*scc_id, base, recursive, *delta_rel, *full_rel)
289            }
290            RirNode::TensorMaskedJoin {
291                mask_name,
292                schema_size,
293                left_keys,
294                right_keys,
295                rel_index,
296                head_rel_name,
297                max_active_rules,
298                head_projection,
299                ..
300            } => self.execute_tensor_masked_join(
301                mask_name,
302                *schema_size,
303                left_keys,
304                right_keys,
305                rel_index,
306                head_rel_name,
307                *max_active_rules,
308                head_projection,
309            ),
310            // Defensive fallback descent for any
311            // `execute_node` caller that bypasses the WCOJ dispatch
312            // hook (probabilistic eval, neural store walks, etc.).
313            // The non-recursive arm in `recursive.rs` short-circuits
314            // dispatch-eligible bodies before reaching here; this
315            // arm is the safety net for everyone else.
316            RirNode::MultiWayJoin { fallback, .. } | RirNode::ChainJoin { fallback, .. } => {
317                self.record_wcoj_body_fallback(node);
318                self.execute_node(fallback)
319            }
320        }
321    }
322
323    fn project_chain_parts(node: &RirNode) -> (&RirNode, Vec<&[ProjectExpr]>) {
324        let mut projections = Vec::new();
325        let mut base = node;
326        while let RirNode::Project { input, columns } = base {
327            projections.push(columns.as_slice());
328            base = input;
329        }
330        projections.reverse();
331        (base, projections)
332    }
333
334    fn execute_project_chain(&mut self, node: &RirNode) -> Result<CudaBuffer> {
335        let (base, projections) = Self::project_chain_parts(node);
336        let mut result = self.execute_node(base)?;
337        for columns in projections {
338            let input = result;
339            let input_rows = input.num_rows();
340            let start = self.profiler.start_op();
341            let projected = self.execute_project(&input, columns)?;
342            if let Some(start) = start {
343                let mem = self.provider.memory().allocated_bytes();
344                self.profiler
345                    .record_op("project", input_rows, projected.num_rows(), start, mem);
346                self.profiler.record_peak_memory(mem);
347            }
348            result = projected;
349        }
350        Ok(result)
351    }
352
353    /// Execute a Join node
354    ///
355    /// Delegates to the kernel provider's hash_join_v2 which supports all join types natively.
356    #[expect(
357        clippy::too_many_arguments,
358        reason = "join dispatch keeps the two inputs, key sets, join semantics, and optional relation identities explicit"
359    )]
360    fn execute_join(
361        &mut self,
362        left: &CudaBuffer,
363        right: &CudaBuffer,
364        left_keys: &[usize],
365        right_keys: &[usize],
366        join_type: JoinType,
367        left_rel: Option<RelId>,
368        right_rel: Option<RelId>,
369    ) -> Result<CudaBuffer> {
370        // Convert IR JoinType to CUDA JoinType (used by adaptive
371        // indexing and the hash fallback below).
372        let cuda_join_type = match join_type {
373            JoinType::Inner => CudaJoinType::Inner,
374            JoinType::Semi => CudaJoinType::Semi,
375            JoinType::Anti => CudaJoinType::Anti,
376            JoinType::LeftOuter => CudaJoinType::LeftOuter,
377        };
378
379        // Output buffer set by nested-loop dispatch,
380        // adaptive indexing, or the hash fallback. All three
381        // paths flow through the shared `record_join_result`
382        // feedback block at the end of this fn.
383        let mut out: Option<CudaBuffer> = None;
384
385        // Nested-loop dispatch precedes adaptive indexing
386        // and hash fallback. On predicate + threshold pass,
387        // route to `nested_loop_join_v2_inner_u32_1key` and
388        // bump the dispatch counter; do NOT early-return —
389        // leave the result in `out` so the shared feedback
390        // block observes it. Otherwise leave `out` unchanged.
391        //
392        // Threshold check uses logical row counts via
393        // `provider.device_row_count(...)` (NOT `row_cap`), with
394        // `checked_mul` fail-closed on overflow before comparing
395        // against the nested-loop total-row threshold.
396        if eligible_for_nested_loop(left, right, left_keys, right_keys, join_type) {
397            let num_left = self.provider.device_row_count(left)? as u64;
398            let num_right = self.provider.device_row_count(right)? as u64;
399            let in_threshold = num_left
400                .checked_mul(num_right)
401                .map(|p| p <= NESTED_LOOP_TOTAL_THRESHOLD)
402                .unwrap_or(false);
403            if in_threshold {
404                out = Some(self.provider.nested_loop_join_v2_inner_u32_1key(
405                    left,
406                    right,
407                    left_keys[0],
408                    right_keys[0],
409                )?);
410                self.nested_loop_dispatch_count += 1;
411            }
412        }
413
414        // Adaptive indexing: opportunistically reuse cached
415        // build-side hash tables when the right side is a base
416        // relation scan and has become "hot" in runtime
417        // statistics. Only runs if nested-loop dispatch did not
418        // dispatch.
419        if out.is_none() && self.config.resolved_persistent_hash_indexes()? {
420            if let Some(build_rel) = right_rel {
421                let build_heat = self
422                    .stats
423                    .get_relation_stats(build_rel)
424                    .map(|s| s.heat)
425                    .unwrap_or(0.0);
426                let est_index_bytes = estimate_join_index_bytes(right, right_keys);
427                let budget_bytes = self.provider.memory().budget().device_bytes;
428                let remaining_bytes = self.provider.memory().remaining_bytes();
429
430                let should_index = self.join_index_cache.should_build(
431                    est_index_bytes,
432                    build_heat,
433                    remaining_bytes,
434                    budget_bytes,
435                );
436
437                if let Some(build_name) = self.get_rel_name(build_rel).map(|s| s.to_string()) {
438                    if let Some(version) = self.store.version(&build_name) {
439                        let key = JoinIndexKey::new(
440                            build_rel,
441                            version,
442                            right_keys.to_vec(),
443                            right.schema(),
444                            self.provider.device().ordinal() as u32,
445                        );
446
447                        let indexed_result = {
448                            self.join_index_cache.get(&key).map(|index| {
449                                self.provider.hash_join_v2_with_index(
450                                    left,
451                                    right,
452                                    left_keys,
453                                    right_keys,
454                                    cuda_join_type,
455                                    index,
456                                    None,
457                                )
458                            })
459                        };
460                        if let Some(indexed_result) = indexed_result {
461                            match indexed_result {
462                                Ok(joined) => out = Some(joined),
463                                Err(err) if is_join_index_mismatch(&err) => {
464                                    self.join_index_cache.remove_stale(&key);
465                                }
466                                Err(err) => return Err(err),
467                            }
468                        } else if should_index {
469                            let background_build = self
470                                .config
471                                .resolved_persistent_hash_index_background_build()?;
472                            if background_build {
473                                self.join_index_cache.record_background_build_request();
474                            }
475                            let build_result = if background_build {
476                                self.provider
477                                    .build_join_index_v2_background(right, right_keys)
478                            } else {
479                                self.provider.build_join_index_v2(right, right_keys)
480                            };
481                            match build_result {
482                                Ok(index) => {
483                                    if background_build {
484                                        self.join_index_cache.record_background_build_complete();
485                                        self.join_index_cache.insert(key, index);
486                                        self.join_index_cache.record_background_build_deferred();
487                                        if let Some(stats) =
488                                            self.stats.get_relation_stats_mut(build_rel)
489                                        {
490                                            stats.has_index = true;
491                                        }
492                                    } else {
493                                        match self.provider.hash_join_v2_with_index(
494                                            left,
495                                            right,
496                                            left_keys,
497                                            right_keys,
498                                            cuda_join_type,
499                                            &index,
500                                            None,
501                                        ) {
502                                            Ok(joined) => {
503                                                self.join_index_cache.insert(key, index);
504                                                if let Some(stats) =
505                                                    self.stats.get_relation_stats_mut(build_rel)
506                                                {
507                                                    stats.has_index = true;
508                                                }
509                                                out = Some(joined);
510                                            }
511                                            Err(err) if is_join_index_mismatch(&err) => {}
512                                            Err(err) => return Err(err),
513                                        }
514                                    }
515                                }
516                                Err(_) => {
517                                    // If indexing fails (e.g., memory pressure), fall back to normal join.
518                                }
519                            }
520                        }
521                    }
522                }
523            }
524        } // end adaptive-indexing gate
525
526        let out = match out {
527            Some(buf) => buf,
528            None => {
529                self.provider
530                    .hash_join_v2(left, right, left_keys, right_keys, cuda_join_type)?
531            }
532        };
533
534        if let (Some(l), Some(r)) = (left_rel, right_rel) {
535            let input_rows = left.num_rows().saturating_mul(right.num_rows());
536            self.record_adaptive_join_observation(
537                l,
538                r,
539                left_keys,
540                right_keys,
541                input_rows,
542                out.num_rows(),
543            );
544            self.stats.record_join_result(
545                l,
546                r,
547                left_keys.to_vec(),
548                right_keys.to_vec(),
549                input_rows,
550                out.num_rows(),
551            );
552        }
553
554        Ok(out)
555    }
556
557    /// Execute a GroupBy node
558    ///
559    /// Delegates to the kernel provider's groupby_multi_agg for multi-aggregation support.
560    fn execute_groupby(
561        &self,
562        input: &CudaBuffer,
563        key_cols: &[usize],
564        aggs: &[(usize, AggOp)],
565    ) -> Result<CudaBuffer> {
566        if aggs.is_empty() {
567            // No aggregations: just distinct on key columns
568            return self.provider.dedup(input, key_cols);
569        }
570
571        // Use multi-aggregation groupby
572        self.provider.groupby_multi_agg(input, key_cols, aggs)
573    }
574
575    /// Execute a Union node
576    ///
577    /// Combines multiple input buffers into one using GPU-native operation.
578    pub(super) fn execute_union(&self, inputs: &[CudaBuffer]) -> Result<CudaBuffer> {
579        if inputs.is_empty() {
580            return self.provider.create_empty_buffer(Schema::new(vec![]));
581        }
582
583        if inputs.len() == 1 {
584            return self.clone_buffer(&inputs[0]);
585        }
586
587        // Multiway union: one concat + sort + dedup over all inputs instead
588        // of re-sorting a growing accumulator per input.
589        let input_refs: Vec<&CudaBuffer> = inputs.iter().collect();
590        self.provider.union_many_gpu(&input_refs)
591    }
592
593    /// Execute a Distinct node
594    ///
595    /// Removes duplicate rows based on key columns.
596    pub(super) fn execute_distinct(
597        &self,
598        input: &CudaBuffer,
599        key_cols: &[usize],
600    ) -> Result<CudaBuffer> {
601        self.provider.dedup(input, key_cols)
602    }
603
604    /// Execute a Diff node
605    ///
606    /// Returns rows in left that are not in right using GPU-native operation.
607    pub(super) fn execute_diff(&self, left: &CudaBuffer, right: &CudaBuffer) -> Result<CudaBuffer> {
608        self.provider.diff_gpu(left, right)
609    }
610
611    #[expect(
612        clippy::too_many_arguments,
613        reason = "tensor-masked join dispatch keeps the registered mask, relation catalog, output identity, and projection contract explicit"
614    )]
615    fn execute_tensor_masked_join(
616        &mut self,
617        mask_name: &str,
618        schema_size: usize,
619        left_keys: &[usize],
620        right_keys: &[usize],
621        rel_index: &[(RelId, String)],
622        head_rel_name: &str,
623        max_active_rules: usize,
624        head_projection: &[usize],
625    ) -> Result<CudaBuffer> {
626        // No-op when no mask is registered. Return an empty buffer with
627        // the head relation's schema (not Schema::new(vec![])) to prevent
628        // schema corruption when execute_non_recursive_scc stores the result.
629        let ilp_mask = match self.ilp_registry.get_mask(mask_name) {
630            Some(mask) => mask,
631            None => {
632                self.ilp_last_result = Some(IlpTaggedResult {
633                    entries: Vec::new(),
634                });
635                // Fail hard if the head relation is missing from the store.
636                let schema = self
637                    .store
638                    .get(head_rel_name)
639                    .map(|buf| buf.schema().clone())
640                    .ok_or_else(|| {
641                        XlogError::Execution(format!(
642                            "TensorMaskedJoin: head relation '{}' not found in store \
643                         (was load_facts_into_store called?)",
644                            head_rel_name
645                        ))
646                    })?;
647                return self.provider.create_empty_buffer(schema);
648            }
649        };
650
651        let start = self.profiler.start_op();
652
653        let head_k = rel_index
654            .iter()
655            .position(|(_, name)| name == head_rel_name)
656            .ok_or_else(|| {
657                XlogError::Execution(format!(
658                    "TensorMaskedJoin: head relation '{}' not found in rel_index",
659                    head_rel_name
660                ))
661            })? as u32;
662
663        let mut tag_entries: Vec<IlpTagEntry> = Vec::new();
664        let mut process_rule = |i: u32,
665                                j: u32,
666                                k: u32,
667                                strict_candidate_idx: Option<usize>,
668                                strict_flags: Option<&CudaBuffer>|
669         -> Result<()> {
670            if k != head_k {
671                return Ok(());
672            }
673
674            let (_, left_name) = &rel_index[i as usize];
675            let (_, right_name) = &rel_index[j as usize];
676
677            let left_buf = match self.store.get(left_name) {
678                Some(buf) if buf.arity() > 0 => buf,
679                _ => return Ok(()),
680            };
681            let right_buf = match self.store.get(right_name) {
682                Some(buf) if buf.arity() > 0 => buf,
683                _ => return Ok(()),
684            };
685
686            // Skip arity-mismatched relations: the join keys are fixed by
687            // the learnable rule template, so the mapped relation must have
688            // enough columns for every key index. Relations with matching
689            // arity but different semantic column meanings will join without
690            // error; semantic correctness of the mask is the optimizer's
691            // responsibility.
692            let left_max_key = left_keys.iter().copied().max().unwrap_or(0);
693            let right_max_key = right_keys.iter().copied().max().unwrap_or(0);
694            if left_buf.arity() <= left_max_key || right_buf.arity() <= right_max_key {
695                return Ok(());
696            }
697
698            let joined = self.provider.hash_join_v2(
699                left_buf,
700                right_buf,
701                left_keys,
702                right_keys,
703                CudaJoinType::Inner,
704            )?;
705
706            // Project join result to head schema columns if projection is specified.
707            // The join produces [left_cols..., right_cols...] but the head may only
708            // need a subset (e.g. reach(X,Y) from b1(X,Z) join b2(Z,Y) needs cols 0,3).
709            let projected = if !head_projection.is_empty() && head_projection.len() < joined.arity()
710            {
711                let proj_exprs: Vec<ProjectExpr> = head_projection
712                    .iter()
713                    .map(|&col| ProjectExpr::Column(col))
714                    .collect();
715                self.execute_project(&joined, &proj_exprs)?
716            } else {
717                joined
718            };
719
720            let projected = if let (Some(candidate_idx), Some(active_flags)) =
721                (strict_candidate_idx, strict_flags)
722            {
723                self.provider.filter_buffer_by_candidate_flag(
724                    &projected,
725                    active_flags,
726                    candidate_idx,
727                )?
728            } else {
729                projected
730            };
731
732            // Use the public helper instead of the private device_row_count.
733            let num_rows = read_device_row_count(&self.provider, &projected)? as u32;
734
735            if num_rows > 0 {
736                tag_entries.push(IlpTagEntry {
737                    i,
738                    j,
739                    k,
740                    num_rows,
741                    buffer: Some(projected),
742                });
743            }
744            Ok(())
745        };
746
747        let active_rule_count = match ilp_mask {
748            IlpMask::Dense { hard, soft, .. } => {
749                let active_rules = self.provider.extract_active_rule_indices(
750                    hard,
751                    soft,
752                    schema_size,
753                    max_active_rules,
754                )?;
755                let count = active_rules.len() as u64;
756                for &(i, j, k) in &active_rules {
757                    process_rule(i, j, k, None, None)?;
758                }
759                count
760            }
761            IlpMask::Sparse { active_entries, .. } => {
762                let limit = max_active_rules.min(active_entries.len());
763                for &(i, j, k) in &active_entries[..limit] {
764                    process_rule(i, j, k, None, None)?;
765                }
766                limit as u64
767            }
768            IlpMask::SparseDevice {
769                candidate_order,
770                active_flags,
771                selected_count,
772                ..
773            } => {
774                if *selected_count > 0 {
775                    for (candidate_idx, &(i, j, k)) in candidate_order.iter().enumerate() {
776                        process_rule(i, j, k, Some(candidate_idx), Some(active_flags))?;
777                    }
778                }
779                (*selected_count).min(max_active_rules) as u64
780            }
781        };
782
783        // Union per-rule results by head relation index, borrowing buffers from tag_entries.
784        let mut bufs_by_k: HashMap<u32, Vec<&CudaBuffer>> = HashMap::new();
785        for entry in &tag_entries {
786            if let Some(ref buf) = entry.buffer {
787                bufs_by_k.entry(entry.k).or_default().push(buf);
788            }
789        }
790
791        for (k, buffers) in bufs_by_k {
792            let (_, target_name) = &rel_index[k as usize];
793
794            // One multiway union per head instead of one union per buffer:
795            // re-sorting a growing accumulator per rule goes quadratic in
796            // rules per head. A single buffer deduplicates the same way the
797            // old union-with-empty did.
798            let union_buf = self.provider.union_many_gpu(&buffers)?;
799
800            // Diff against existing and merge
801            if let Some(existing) = self.store.get(target_name) {
802                let delta = self.provider.diff_gpu(&union_buf, existing)?;
803                if !delta.is_empty() {
804                    let merged = self.provider.union_gpu(existing, &delta)?;
805                    self.store_put(target_name, merged);
806                }
807            } else {
808                let key_cols: Vec<usize> = (0..union_buf.arity()).collect();
809                let deduped = self.provider.dedup(&union_buf, &key_cols)?;
810                self.store_put(target_name, deduped);
811            }
812        }
813
814        // Store tag entries with retained buffers.
815        self.ilp_last_result = Some(IlpTaggedResult {
816            entries: tag_entries,
817        });
818
819        if let Some(start) = start {
820            let mem = self.provider.memory().allocated_bytes();
821            self.profiler
822                .record_op("TensorMaskedJoin", 0, active_rule_count, start, mem);
823        }
824
825        // Return empty with head schema (results routed via store).
826        let schema = self
827            .store
828            .get(head_rel_name)
829            .map(|buf| buf.schema().clone())
830            .ok_or_else(|| {
831                XlogError::Execution(format!(
832                    "TensorMaskedJoin: head relation '{}' not found in store \
833                 (was load_facts_into_store called?)",
834                    head_rel_name
835                ))
836            })?;
837        self.provider.create_empty_buffer(schema)
838    }
839}