xlog_runtime/executor/recursive.rs
1//! Recursive SCC execution using semi-naive fixpoint iteration.
2
3use std::collections::{BTreeSet, HashMap, HashSet};
4use std::time::Instant;
5
6use xlog_core::{RelId, Result, Schema, XlogError};
7use xlog_cuda::{CudaBuffer, CudaKernelProvider};
8use xlog_ir::{ExecutionPlan, RirNode, Stratum};
9
10use crate::profiler::Profiler;
11
12use super::delta::DeltaRelationTracker;
13use super::Executor;
14
15impl Executor {
16 /// Union a batch of same-head contributions in one multiway pass,
17 /// recording a single profiled "union" op for the whole batch.
18 ///
19 /// Takes the provider and profiler as explicit arguments (instead of
20 /// `&mut self`) so call sites can hold `self.store` borrows across the
21 /// call; the field borrows stay disjoint.
22 fn union_batch_profiled(
23 provider: &CudaKernelProvider,
24 profiler: &mut Profiler,
25 inputs: &[&CudaBuffer],
26 ) -> Result<CudaBuffer> {
27 let union_input: u64 = inputs.iter().map(|b| b.num_rows()).sum();
28 let start = profiler.start_op();
29 let merged = provider.union_many_gpu(inputs)?;
30 if let Some(start) = start {
31 let mem = provider.memory().allocated_bytes();
32 profiler.record_op("union", union_input, merged.num_rows(), start, mem);
33 profiler.record_peak_memory(mem);
34 }
35 Ok(merged)
36 }
37
38 /// For a `MultiWayJoin` or `ChainJoin` body, try the specialized WCOJ
39 /// dispatchers first; on decline, fall back to the embedded fallback
40 /// subtree via `execute_node`. For any other RIR variant, defer to
41 /// `execute_node` directly.
42 ///
43 /// Used at two sites in the recursive engine: the seeding pass, where
44 /// stable rules and recursive rules get their initial dispatch on the full
45 /// body, and the per-variant loop, where each recursive scan with a
46 /// non-empty delta is rewritten to its delta `RelId` for one dispatch.
47 /// Multi-recursive bodies, including distinct recursive predicates and
48 /// same-predicate self-recursive bodies, reach a `MultiWayJoin` here after
49 /// the promoter admits bodies with more than one recursive scan; the
50 /// per-variant rewrite loop builds one variant per recursive occurrence
51 /// with a non-empty delta and dispatches each via this helper.
52 ///
53 /// Counter semantics: `wcoj_*_dispatch_count` increments once per
54 /// successful WCOJ kernel result: once per recursive rule, iteration, and
55 /// variant. Non-recursive dispatch sites increment once per rule per call.
56 fn execute_wcoj_or_fallback_node(&mut self, node: &RirNode) -> Result<CudaBuffer> {
57 if let RirNode::ChainJoin { .. } = node {
58 if let Some(buf) = self.try_dispatch_chain_on_body(node)? {
59 return Ok(buf);
60 }
61 return self.execute_node(node);
62 }
63 if let RirNode::MultiWayJoin { .. } = node {
64 // Triangle, 4-cycle, then K-clique. A body cannot
65 // match more than one paper-derived shape (different
66 // atom counts). The dispatcher's own gate handles
67 // env-var / config / adaptive decisions; this site is
68 // purely structural.
69 if let Some(buf) = self.try_dispatch_wcoj_triangle_on_body(node)? {
70 return Ok(buf);
71 }
72 if let Some(buf) = self.try_dispatch_wcoj_4cycle_on_body(node)? {
73 return Ok(buf);
74 }
75 // Recursive clique bodies use the same launch-local metadata
76 // builders as non-recursive K-clique dispatch, so rewritten
77 // semi-naive variants are eligible here too.
78 if let Some(buf) = self.try_dispatch_wcoj_clique5_on_body(node)? {
79 return Ok(buf);
80 }
81 if let Some(buf) = self.try_dispatch_wcoj_clique6_on_body(node)? {
82 return Ok(buf);
83 }
84 if let Some(buf) = self.try_dispatch_wcoj_clique7_on_body(node)? {
85 return Ok(buf);
86 }
87 if let Some(buf) = self.try_dispatch_wcoj_clique8_on_body(node)? {
88 return Ok(buf);
89 }
90 // Generalized Free Join dispatch for every multiway shape the
91 // dedicated dispatchers declined. The hook re-checks dedicated
92 // shapes structurally, so it only fires on general bodies.
93 if let Some(buf) = self.try_dispatch_free_join(node)? {
94 return Ok(buf);
95 }
96 }
97 self.execute_node(node)
98 }
99
100 fn refresh_kclique_edge_metadata_after_merge(
101 &mut self,
102 rules: &[xlog_ir::CompiledRule],
103 pred: &str,
104 ) {
105 let start = Instant::now();
106 let affected_rules = rules
107 .iter()
108 .filter(|rule| self.kclique_body_mentions_pred(&rule.body, pred))
109 .count() as u64;
110 self.record_kclique_histogram_refresh_time(start, affected_rules);
111 }
112
113 fn record_kclique_histogram_refresh_time(&mut self, start: Instant, affected_rules: u64) {
114 if affected_rules == 0 {
115 return;
116 }
117 self.kclique_histogram_refresh_count = self
118 .kclique_histogram_refresh_count
119 .saturating_add(affected_rules);
120 self.kclique_histogram_refresh_nanos = self
121 .kclique_histogram_refresh_nanos
122 .saturating_add(start.elapsed().as_nanos());
123 }
124
125 fn kclique_body_mentions_pred(&self, node: &RirNode, pred: &str) -> bool {
126 let RirNode::MultiWayJoin {
127 inputs, var_order, ..
128 } = node
129 else {
130 return false;
131 };
132 let Some(order) = var_order.as_ref().and_then(|order| order.kclique.as_ref()) else {
133 return false;
134 };
135 if !matches!(order.k, 5..=8) {
136 return false;
137 }
138 inputs.iter().any(|input| {
139 let RirNode::Scan { rel } = input else {
140 return false;
141 };
142 self.rel_names.get(rel).is_some_and(|name| name == pred)
143 })
144 }
145
146 /// Stub: always returns an error directing callers to use `execute_plan` instead.
147 pub fn execute_stratum(&mut self, _stratum: &Stratum) -> Result<()> {
148 Err(XlogError::Execution(
149 "execute_stratum cannot be called directly; use execute_plan instead which provides \
150 the required rules_by_scc context"
151 .to_string(),
152 ))
153 }
154
155 /// Execute all rules in a non-recursive strongly connected component once.
156 ///
157 /// Each contiguous run of same-head rules is merged into the store with
158 /// one multiway union instead of one union per rule, so many-rule heads
159 /// stay linear in their total rows. Flushing on every head switch (not
160 /// once per SCC group) preserves rule-order dataflow: promoter-generated
161 /// helper rules share an SCC group with their consumer, so a helper's
162 /// head must be installed before the next rule reads it.
163 pub fn execute_non_recursive_scc(&mut self, rules: &[xlog_ir::CompiledRule]) -> Result<()> {
164 let mut pending_head: Option<&str> = None;
165 let mut pending: Vec<CudaBuffer> = Vec::new();
166 for rule in rules {
167 if pending_head != Some(rule.head.as_str()) {
168 if let Some(head) = pending_head.take() {
169 let batch = std::mem::take(&mut pending);
170 self.install_plain_head_batch(head, batch)?;
171 }
172 pending_head = Some(rule.head.as_str());
173 } else if !pending.is_empty() && self.body_reads_own_head(rule) {
174 // A same-head rule that reads its own head (a non-monotone
175 // singleton SCC, admitted under the probabilistic profile)
176 // must observe prior same-head contributions exactly as the
177 // sequential per-rule path did: flush before evaluating it.
178 let batch = std::mem::take(&mut pending);
179 self.install_plain_head_batch(&rule.head, batch)?;
180 }
181 let result = self.execute_node(&rule.body)?;
182 pending.push(result);
183 }
184 if let Some(head) = pending_head {
185 self.install_plain_head_batch(head, pending)?;
186 }
187 Ok(())
188 }
189
190 /// Whether the rule's body reads its own head relation in a way that is
191 /// sensitive to same-pass installs. A body that IS a bare scan of the
192 /// head (`h :- h`, the planner's identity/carry rule for fact
193 /// predicates) is exempt: its scan result is always a subset of the
194 /// final merged relation, so batching cannot change the outcome. Any
195 /// other self-reading shape (negation, joins, or projections over the
196 /// head — possible only for non-monotone singleton SCCs admitted under
197 /// the probabilistic profile) must flush for sequential parity.
198 fn body_reads_own_head(&self, rule: &xlog_ir::CompiledRule) -> bool {
199 if let RirNode::Scan { rel } = &rule.body {
200 if self.get_rel_name(*rel).is_some_and(|n| n == rule.head) {
201 return false;
202 }
203 }
204 let mut scans = Vec::new();
205 Self::collect_scan_rels(&rule.body, &mut scans);
206 scans
207 .into_iter()
208 .any(|rel| self.get_rel_name(rel).is_some_and(|n| n == rule.head))
209 }
210
211 /// Install one head's batched results, skipping empty contributions.
212 /// Mirrors the pre-batching per-rule behavior: an existing relation is
213 /// left untouched when every contribution is empty, and a lone fresh
214 /// non-empty result records a single-input union (internally one dedup)
215 /// like the dispatched install path, so `--stats` accounting stays
216 /// uniform across both installers. The store is only mutated after the
217 /// merge succeeds, so a failed union leaves the existing relation
218 /// intact.
219 fn install_plain_head_batch(&mut self, head: &str, results: Vec<CudaBuffer>) -> Result<()> {
220 let non_empty: Vec<&CudaBuffer> = results.iter().filter(|r| !r.is_empty()).collect();
221
222 // An existing empty relation (e.g. a pre-seeded schema buffer)
223 // carries no rows to merge, so it takes the fresh-install path,
224 // matching the dispatched installer.
225 let existing = self.store.get(head).filter(|buf| !buf.is_empty());
226 if let Some(existing) = existing {
227 if non_empty.is_empty() {
228 // No new rows for this head: leave the relation untouched.
229 return Ok(());
230 }
231 let mut union_inputs = Vec::with_capacity(non_empty.len() + 1);
232 union_inputs.push(existing);
233 union_inputs.extend(non_empty);
234 let merged =
235 Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
236 self.store_put(head, merged);
237 } else if non_empty.is_empty() {
238 // All contributions are empty: an absent head gets an empty
239 // relation with the result schema; an existing (empty) head is
240 // left untouched.
241 if self.store.get(head).is_none() {
242 let first = results.into_iter().next().ok_or_else(|| {
243 XlogError::Execution(format!("No results collected for head {}", head))
244 })?;
245 self.store_put(head, first);
246 }
247 } else {
248 let merged =
249 Self::union_batch_profiled(&self.provider, &mut self.profiler, &non_empty)?;
250 self.store_put(head, merged);
251 }
252 Ok(())
253 }
254
255 /// Install one head's batched non-recursive results with a single
256 /// profiled multiway union. Each entry's flag records whether the
257 /// route's output is already sorted+deduped, so a lone fresh WCOJ
258 /// result installs without a redundant dedup pass, mirroring the
259 /// per-route install behavior. The store is only mutated after the
260 /// merge succeeds, so a failed union leaves the existing relation
261 /// intact.
262 fn install_dispatched_head_batch(
263 &mut self,
264 head: &str,
265 results: Vec<(CudaBuffer, bool)>,
266 ) -> Result<()> {
267 // Union with existing result if the predicate already has rows. An
268 // existing empty relation (e.g. a pre-seeded schema buffer)
269 // contributes nothing, so it takes the fresh-install path — which
270 // is what lets a lone already-deduped WCOJ result skip the
271 // redundant dedup in production runs.
272 let existing = self.store.get(head).filter(|buf| !buf.is_empty());
273 if let Some(existing) = existing {
274 let mut union_inputs: Vec<&CudaBuffer> = Vec::with_capacity(results.len() + 1);
275 union_inputs.push(existing);
276 union_inputs.extend(results.iter().map(|(buf, _)| buf));
277 let merged =
278 Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
279 self.store_put(head, merged);
280 } else if results.len() == 1 {
281 let (result, already_deduped) = results.into_iter().next().expect("len checked");
282 if already_deduped || result.is_empty() {
283 self.store_put(head, result);
284 } else {
285 // Set semantics for a lone raw result: a single-input
286 // multiway union (internally one dedup), recorded as a
287 // "union" op like the union-with-empty install it replaces.
288 let merged =
289 Self::union_batch_profiled(&self.provider, &mut self.profiler, &[&result])?;
290 self.store_put(head, merged);
291 }
292 } else {
293 let union_inputs: Vec<&CudaBuffer> = results.iter().map(|(buf, _)| buf).collect();
294 let merged =
295 Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
296 self.store_put(head, merged);
297 }
298 Ok(())
299 }
300
301 /// Execute a stratum (internal implementation)
302 ///
303 /// Processes all SCCs in the stratum by executing their rules.
304 /// For recursive SCCs, uses semi-naive fixpoint iteration.
305 pub(super) fn execute_stratum_impl(
306 &mut self,
307 stratum: &Stratum,
308 plan: &ExecutionPlan,
309 ) -> Result<()> {
310 // Process each SCC in the stratum
311 for &scc_id in &stratum.sccs {
312 // Get rules for this SCC
313 if let Some(rules) = plan.rules_by_scc.get(scc_id as usize) {
314 // Get SCC metadata
315 let scc = plan.sccs.get(scc_id as usize);
316 let is_recursive = scc.map(|s| s.is_recursive).unwrap_or(false);
317
318 if is_recursive {
319 // Recursive SCC: use semi-naive fixpoint iteration. The
320 // recursive engine invokes WCOJ dispatch via
321 // `execute_wcoj_or_fallback_node` on both the seeding
322 // pass and per-variant evaluation when the promoted body
323 // shape is eligible.
324 self.execute_recursive_scc(rules)?;
325 } else {
326 // Non-recursive SCC: execute rules once, merging each
327 // contiguous run of same-head results with one multiway
328 // union instead of one union per rule, so many-rule heads
329 // stay linear in their total rows. Flushing on every head
330 // switch (not once per SCC group) preserves rule-order
331 // dataflow: promoter-generated helper rules share an SCC
332 // group with their consumer, so a helper's head must be
333 // installed before the next rule dispatches against it.
334 let mut pending_head: Option<&str> = None;
335 let mut pending: Vec<(CudaBuffer, bool)> = Vec::new();
336 for rule in rules {
337 if pending_head != Some(rule.head.as_str()) {
338 if let Some(head) = pending_head.take() {
339 let batch = std::mem::take(&mut pending);
340 self.install_dispatched_head_batch(head, batch)?;
341 }
342 pending_head = Some(rule.head.as_str());
343 } else if !pending.is_empty() && self.body_reads_own_head(rule) {
344 // A same-head rule that reads its own head (a
345 // non-monotone singleton SCC, admitted under the
346 // probabilistic profile) must observe prior
347 // same-head contributions exactly as the
348 // sequential per-rule path did: flush before
349 // evaluating it.
350 let batch = std::mem::take(&mut pending);
351 self.install_dispatched_head_batch(&rule.head, batch)?;
352 }
353
354 // Route two-atom ChainJoin bodies before the
355 // triangle/4-cycle/KC attempts. The dispatcher
356 // silently declines on non-chain bodies or when
357 // the env gate disables the route.
358 let entry = if let Some(chain_result) =
359 self.try_dispatch_chain_on_body(&rule.body)?
360 {
361 (chain_result, false)
362 }
363 // WCOJ triangle dispatch, gated by runtime configuration.
364 // Try to short-circuit the rule via the GPU
365 // 3-way kernel. On Some(_), record the result
366 // and skip the binary-join path for this rule.
367 // On None (gate off, shape mismatch, missing
368 // input, kernel error), fall through silently.
369 // See `wcoj_dispatch::try_dispatch_wcoj_triangle`
370 // for the full match contract. WCOJ output is
371 // already sorted+deduped, so a lone fresh
372 // install needs no dedup pass.
373 else if let Some(wcoj_result) = self.try_dispatch_wcoj_triangle(rule)? {
374 (wcoj_result, true)
375 }
376 // WCOJ 4-cycle dispatch.
377 // Same pattern as triangle. Order is a doc
378 // anchor — a body cannot match both shapes
379 // (different atom counts), so triangle's
380 // earlier attempt always returns None on a
381 // 4-cycle body and vice versa.
382 else if let Some(wcoj_result) = self.try_dispatch_wcoj_4cycle(rule)? {
383 (wcoj_result, true)
384 }
385 // K-clique dispatch for k=5..k=8.
386 // Same shape-gated default-dispatch
387 // pattern as triangle / 4-cycle; silent
388 // fallback to MultiWayJoin.fallback on
389 // dispatcher decline or kernel error.
390 else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique5(rule)? {
391 (wcoj_result, true)
392 } else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique6(rule)? {
393 (wcoj_result, true)
394 } else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique7(rule)? {
395 (wcoj_result, true)
396 } else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique8(rule)? {
397 (wcoj_result, true)
398 }
399 // Generalized Free Join dispatch for every multiway
400 // shape the dedicated dispatchers above declined. The
401 // dispatcher re-checks those shapes structurally, so
402 // it only fires on general bodies. Unlike the
403 // dedicated kernels, the frontier engine emits one row
404 // per derivation path, so its output still needs the
405 // dedup the per-head merge (or the fresh-install
406 // dedup) provides.
407 else if let Some(fj_result) = self.try_dispatch_free_join(&rule.body)? {
408 (fj_result, false)
409 } else {
410 // When WCOJ dispatch declines on a `MultiWayJoin`
411 // body (gate off, kernel error, adaptive score below
412 // threshold, ...), execute the embedded `fallback`,
413 // the post-optimizer binary-join tree the promoter
414 // captured. `execute_node`'s `MultiWayJoin` arm is the
415 // defensive safety net; explicit destructuring here
416 // keeps the intent visible at the dispatch site.
417 let body_to_execute = match &rule.body {
418 xlog_ir::RirNode::MultiWayJoin { fallback, .. }
419 | xlog_ir::RirNode::ChainJoin { fallback, .. } => fallback.as_ref(),
420 other => other,
421 };
422 self.record_wcoj_body_fallback(&rule.body);
423 (self.execute_node(body_to_execute)?, false)
424 };
425
426 pending.push(entry);
427 }
428 if let Some(head) = pending_head {
429 self.install_dispatched_head_batch(head, pending)?;
430 }
431 }
432 }
433 }
434
435 Ok(())
436 }
437
438 /// Execute a recursive SCC using semi-naive fixpoint iteration
439 ///
440 /// The algorithm:
441 /// 1. Execute all rules once to get initial result
442 /// 2. Track which relations changed (delta)
443 /// 3. Re-execute rules, using delta from previous iteration
444 /// 4. Repeat until no changes (fixpoint reached)
445 pub fn execute_recursive_scc(&mut self, rules: &[xlog_ir::CompiledRule]) -> Result<()> {
446 // Reset the per-iteration stats trace at SCC entry so tests see a
447 // fresh trace per invocation. Gated on the `recursive-stats-trace`
448 // feature; default OFF.
449 #[cfg(feature = "recursive-stats-trace")]
450 {
451 self.last_recursive_stats_trace.entries.clear();
452 }
453 // Identify SCC predicates from rule heads (these are the recursive IDBs).
454 let mut recursive_pred_names: BTreeSet<String> = BTreeSet::new();
455 let mut schema_by_pred: HashMap<String, Schema> = HashMap::new();
456 for rule in rules {
457 recursive_pred_names.insert(rule.head.clone());
458 // An empty column list is the complete schema of a nullary predicate,
459 // not evidence that schema metadata is absent.
460 schema_by_pred
461 .entry(rule.head.clone())
462 .or_insert_with(|| rule.meta.schema.clone());
463 }
464 let recursive_pred_lookup: HashSet<String> = recursive_pred_names.iter().cloned().collect();
465 let recursive_preds: Vec<String> = recursive_pred_names.into_iter().collect();
466
467 // Ensure all recursive predicates exist in the store so scans never fail
468 // due to evaluation order (mutual recursion can reference an as-yet-empty relation).
469 for pred in &recursive_preds {
470 if !self.store.contains(pred) {
471 let schema = schema_by_pred
472 .get(pred)
473 .cloned()
474 .or_else(|| self.store.get(pred).map(|b| b.schema().clone()))
475 .ok_or_else(|| {
476 XlogError::Execution(format!(
477 "Missing schema for recursive predicate {}",
478 pred
479 ))
480 })?;
481 let empty = self.create_empty_buffer(schema)?;
482 self.store_put(pred, empty);
483 }
484 }
485
486 // Create per-predicate delta relations (distinct RelIds) so semi-naive evaluation
487 // can target a single recursive Scan occurrence without overriding *all* scans of
488 // that predicate in a rule (required for self-joins like p(X,Y), p(Y,Z)).
489 let mut next_rel_id = self
490 .rel_names
491 .keys()
492 .map(|r| r.0)
493 .max()
494 .unwrap_or(0)
495 .saturating_add(1);
496
497 let mut delta_tracker = DeltaRelationTracker::new();
498 for pred in &recursive_preds {
499 let rel_id = RelId(next_rel_id);
500 next_rel_id = next_rel_id.saturating_add(1);
501 let name = format!("__delta_{}_{}", pred, rel_id.0);
502 self.register_relation(rel_id, &name);
503 delta_tracker.insert(pred.clone(), rel_id, name);
504 }
505
506 // Execute all rules once against the current store to seed initial results.
507 // Accumulate per-head before mutating the store to avoid order dependence.
508 //
509 // Route through `execute_wcoj_or_fallback_node` so promoted
510 // MultiWayJoin bodies for stable and linear-recursive triangles or
511 // 4-cycles get a chance at WCOJ dispatch on the seeding pass. Stable
512 // rules with zero recursive scans only run here, so without this hook
513 // they would never see a kernel.
514 let mut derived_initial: HashMap<String, Vec<CudaBuffer>> = HashMap::new();
515 for rule in rules {
516 let result = self.execute_wcoj_or_fallback_node(&rule.body)?;
517 derived_initial
518 .entry(rule.head.clone())
519 .or_default()
520 .push(result);
521 }
522
523 // Initialize delta from the newly-derived tuples only.
524 //
525 // This supports incremental maintenance: if the SCC is executed again after EDB inserts,
526 // the delta relations start with only the *new* tuples, not a full rescan of the current
527 // fixed point.
528 for pred in &recursive_preds {
529 // Read the prior full relation in place: the store is only
530 // mutated after the merge succeeds, so a failed union leaves the
531 // relation (and its version counter) intact.
532 let full_old = self
533 .store
534 .get(pred)
535 .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", pred)))?;
536
537 let derived = derived_initial.remove(pred).unwrap_or_default();
538
539 // One multiway union per head: the prior full relation and every
540 // same-head seed contribution are concatenated, sorted, and
541 // deduplicated in a single pass instead of one union per rule.
542 let mut union_inputs: Vec<&CudaBuffer> = Vec::with_capacity(derived.len() + 1);
543 union_inputs.push(full_old);
544 union_inputs.extend(derived.iter());
545 let full_new =
546 Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
547 drop(union_inputs);
548 drop(derived);
549
550 let delta_name = delta_tracker.delta_name(pred)?;
551
552 let full_old_rows = self.buffer_row_count(full_old)?;
553 let full_new_rows = self.buffer_row_count(&full_new)?;
554 let delta_initial = if full_new_rows == 0 {
555 self.create_empty_buffer(full_new.schema().clone())?
556 } else if full_old_rows == 0 {
557 self.clone_buffer(&full_new)?
558 } else {
559 let diff_input = full_new.num_rows() + full_old.num_rows();
560 let start = self.profiler.start_op();
561 let diffed = self.provider.diff_gpu(&full_new, full_old)?;
562 if let Some(start) = start {
563 let mem = self.provider.memory().allocated_bytes();
564 self.profiler
565 .record_op("diff", diff_input, diffed.num_rows(), start, mem);
566 self.profiler.record_peak_memory(mem);
567 }
568 diffed
569 };
570
571 // Seed-iteration cardinality refresh. Capture the actual
572 // `delta_initial` row count before the `store_put` move; after the
573 // move, the buffer is gone.
574 let delta_initial_rows = self.buffer_row_count(&delta_initial)? as u64;
575 let seed_full_rows = full_new_rows as u64;
576 // Pre-resolve rel_id lookups before the &mut self stats
577 // borrow below.
578 let full_rel_opt = self.name_to_rel_id(pred);
579 let delta_rel = delta_tracker.delta_rel_id(pred)?;
580
581 self.store_put(pred, full_new);
582 self.store_put(delta_name, delta_initial);
583
584 // Stats updates fire whether or not WCOJ ran on the seed
585 // pass. update_cardinality is a no-op for unregistered
586 // rel_ids (defensive: tests that don't register an IDB
587 // head get a no-op for the full_rel write).
588 if let Some(full_rel) = full_rel_opt {
589 self.stats.update_cardinality(full_rel, seed_full_rows);
590 }
591 self.stats.update_cardinality(delta_rel, delta_initial_rows);
592
593 // Seed stats trace entry, gated on `recursive-stats-trace`.
594 #[cfg(feature = "recursive-stats-trace")]
595 self.last_recursive_stats_trace
596 .entries
597 .push(super::RecursiveStatsTraceEntry {
598 iteration: 0,
599 pred: pred.clone(),
600 full_rel: full_rel_opt.unwrap_or(RelId(u32::MAX)),
601 delta_rel,
602 full_rows: seed_full_rows,
603 delta_rows: delta_initial_rows,
604 phase: super::RecursiveStatsPhase::Seed,
605 binary_est_for_variant: None,
606 });
607 }
608
609 // Iterate until no new tuples are produced.
610 let mut reached_fixpoint = false;
611 let max_iterations = self.config.max_iterations as usize;
612 let mut iteration_count = 0usize;
613 // D3 — per-fixpoint dispatch context for the factorized delta
614 // (domain bounds + normalized EDB statics are cached across
615 // iterations).
616 let mut fd_ctx = super::wcoj_dispatch::FactorizedDeltaCtx::default();
617 for _iteration in 0..max_iterations {
618 iteration_count += 1;
619 // Compute delta_new_raw per head by evaluating each rule once per recursive Scan occurrence.
620 // Contributions are collected unmerged; the per-head finalize
621 // below unions each head's batch in one multiway pass instead of
622 // one union per rule.
623 let mut delta_new_raw_by_head: HashMap<String, Vec<CudaBuffer>> = HashMap::new();
624 // D3 — factorized novel sets per head: already diffed
625 // against the stable relation and full-row deduped at
626 // dispatch time. Kept separate from the raw accumulator so
627 // all-factorized heads can skip the legacy diff entirely.
628 let mut delta_novel_by_head: HashMap<String, Vec<CudaBuffer>> = HashMap::new();
629
630 for rule in rules {
631 let mut scans = Vec::new();
632 Self::collect_scan_rels(&rule.body, &mut scans);
633
634 // Build a list of (rel_id, occurrence_idx, pred_name) for recursive scans.
635 let mut seen: HashMap<RelId, usize> = HashMap::new();
636 let mut variants: Vec<(RelId, usize, String)> = Vec::new();
637 for rel_id in scans {
638 let pred_name = match self.get_rel_name(rel_id) {
639 Some(n) => n.to_string(),
640 None => continue,
641 };
642 if !recursive_pred_lookup.contains(&pred_name) {
643 continue;
644 }
645
646 // Skip variants where the delta for this predicate is empty.
647 let delta_name = match delta_tracker.get(&pred_name) {
648 Some((_rel_id, name)) => name.as_str(),
649 None => continue,
650 };
651 let delta_is_empty = match self.store.get(delta_name) {
652 Some(delta) => self.buffer_row_count(delta)? == 0,
653 None => true,
654 };
655 if delta_is_empty {
656 continue;
657 }
658
659 let occ = seen.entry(rel_id).or_insert(0);
660 variants.push((rel_id, *occ, pred_name));
661 *occ += 1;
662 }
663
664 if variants.is_empty() {
665 // Base rule: it can only contribute on the first seeding pass.
666 continue;
667 }
668
669 let mut rule_delta_raw: Vec<CudaBuffer> = Vec::new();
670 let mut rule_delta_novel: Vec<CudaBuffer> = Vec::new();
671 for (rel_id, occ, pred_name) in variants {
672 let delta_rel_id = delta_tracker.delta_rel_id(&pred_name)?;
673
674 let variant_node =
675 Self::rewrite_scan_nth(&rule.body, rel_id, occ, delta_rel_id).ok_or_else(
676 || {
677 XlogError::Execution(format!(
678 "Failed to rewrite rule body for predicate {}",
679 pred_name
680 ))
681 },
682 )?;
683
684 // Try the factorized delta pipeline first: a qualifying
685 // ChainJoin variant returns the novel set directly
686 // (already diffed against the head's stable relation and
687 // deduped). Declines are silent and fall through to the
688 // legacy path.
689 if let Some(novel) = self.try_dispatch_factorized_delta(
690 &variant_node,
691 delta_rel_id,
692 &rule.head,
693 &recursive_pred_lookup,
694 &mut fd_ctx,
695 )? {
696 rule_delta_novel.push(novel);
697 continue;
698 }
699 self.record_factorized_delta_fallback(&variant_node);
700
701 // Try WCOJ on the rewritten variant body before falling
702 // back to the binary-join walker.
703 // For a linear-recursive triangle/4-cycle, the
704 // variant has one Scan's RelId swapped to its
705 // delta — the kernel reads from the delta store
706 // entry transparently, no special-case dispatch
707 // logic needed.
708 let out = self.execute_wcoj_or_fallback_node(&variant_node)?;
709 rule_delta_raw.push(out);
710 }
711
712 // D3 — a rule with BOTH factorized and legacy variant
713 // outputs folds its novel rows into the raw batch
714 // (the legacy diff is a no-op on novel rows, so this is
715 // sound); an all-factorized rule keeps its novel rows on
716 // the diff-free track.
717 if !rule_delta_raw.is_empty() {
718 rule_delta_raw.append(&mut rule_delta_novel);
719 delta_new_raw_by_head
720 .entry(rule.head.clone())
721 .or_default()
722 .append(&mut rule_delta_raw);
723 } else if !rule_delta_novel.is_empty() {
724 delta_novel_by_head
725 .entry(rule.head.clone())
726 .or_default()
727 .append(&mut rule_delta_novel);
728 }
729 }
730
731 // Finalize delta_new per head: delta_new = dedup(delta_raw - full).
732 delta_tracker.begin_iteration();
733
734 for pred in &recursive_preds {
735 let full = self
736 .store
737 .get(pred)
738 .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", pred)))?;
739 // Capture the current full row count for the trace's
740 // `full_rows` field before this iteration's delta relation is
741 // replaced. Gated on `recursive-stats-trace` so production
742 // builds do not compute it.
743 #[cfg(feature = "recursive-stats-trace")]
744 let pre_phase4_full_rows = self.buffer_row_count(full)? as u64;
745
746 let mut raw_bufs = delta_new_raw_by_head.remove(pred).unwrap_or_default();
747 let mut novel_bufs = delta_novel_by_head.remove(pred).unwrap_or_default();
748 // D3 — when a head received both raw and factorized
749 // contributions (different rules), fold the novel rows
750 // into the raw batch before the legacy diff (sound: the
751 // diff is a no-op on novel rows). An all-factorized
752 // head skips the diff entirely — its novel rows are
753 // already diffed and deduped by construction.
754 if !raw_bufs.is_empty() {
755 raw_bufs.append(&mut novel_bufs);
756 }
757 let delta_new = if !novel_bufs.is_empty() {
758 if novel_bufs.len() == 1 {
759 novel_bufs.pop().expect("len checked")
760 } else {
761 // Novel sets are deduped per rule, not across rules,
762 // so a multi-rule head still unions its novel batch.
763 let union_inputs: Vec<&CudaBuffer> = novel_bufs.iter().collect();
764 Self::union_batch_profiled(
765 &self.provider,
766 &mut self.profiler,
767 &union_inputs,
768 )?
769 }
770 } else if !raw_bufs.is_empty() {
771 let delta_raw = if raw_bufs.len() == 1 {
772 raw_bufs.pop().expect("len checked")
773 } else {
774 // One multiway union per head instead of one union
775 // per rule contribution.
776 let union_inputs: Vec<&CudaBuffer> = raw_bufs.iter().collect();
777 Self::union_batch_profiled(
778 &self.provider,
779 &mut self.profiler,
780 &union_inputs,
781 )?
782 };
783 drop(raw_bufs);
784 if self.buffer_row_count(&delta_raw)? == 0 {
785 self.create_empty_buffer(full.schema().clone())?
786 } else {
787 let diff_input = delta_raw.num_rows() + full.num_rows();
788 let start = self.profiler.start_op();
789 let diffed = self.provider.diff_gpu(&delta_raw, full)?;
790 if let Some(start) = start {
791 let mem = self.provider.memory().allocated_bytes();
792 self.profiler.record_op(
793 "diff",
794 diff_input,
795 diffed.num_rows(),
796 start,
797 mem,
798 );
799 self.profiler.record_peak_memory(mem);
800 }
801 diffed
802 }
803 } else {
804 self.create_empty_buffer(full.schema().clone())?
805 };
806
807 let delta_name = delta_tracker.delta_name(pred)?.to_string();
808 let delta_new_rows = self.buffer_row_count(&delta_new)? as u64;
809 if delta_new_rows != 0 {
810 delta_tracker.mark_changed();
811 }
812 // Pre-resolve rel_id lookups before the &mut self
813 // store_put + stats update below. `full_rel_opt` is
814 // only used by the trace under the
815 // `recursive-stats-trace` feature.
816 #[cfg(feature = "recursive-stats-trace")]
817 let full_rel_opt = self.name_to_rel_id(pred);
818 let delta_rel = delta_tracker.delta_rel_id(pred)?;
819 self.store_put(&delta_name, delta_new);
820
821 // Refresh the delta relation cardinality after computing this
822 // iteration's delta. The full relation cardinality is not
823 // updated here because the full relation has not changed yet
824 // this iteration; the merge step owns that.
825 self.stats.update_cardinality(delta_rel, delta_new_rows);
826
827 // Delta stats trace entry, gated on `recursive-stats-trace`.
828 // binary_est_for_variant captures the cost model's
829 // first-binary-hop estimate for the linear-recursive fixtures
830 // (`pred == "e1"` rewrites
831 // Scan(e1) → Scan(delta_e1); first hop is
832 // `delta_e1.col1 ⋈ e2.col0`). Populated inline because
833 // delta_rel is unregistered at fixpoint exit, so the
834 // test cannot recompute after `execute_plan` returns.
835 #[cfg(feature = "recursive-stats-trace")]
836 let binary_est_for_variant: Option<u64> = if pred == "e1" {
837 self.name_to_rel_id("e2").map(|e2_rel| {
838 self.stats
839 .estimate_join_cardinality(delta_rel, e2_rel, &[1], &[0])
840 })
841 } else {
842 None
843 };
844 #[cfg(feature = "recursive-stats-trace")]
845 self.last_recursive_stats_trace
846 .entries
847 .push(super::RecursiveStatsTraceEntry {
848 iteration: iteration_count,
849 pred: pred.clone(),
850 full_rel: full_rel_opt.unwrap_or(RelId(u32::MAX)),
851 delta_rel,
852 full_rows: pre_phase4_full_rows,
853 delta_rows: delta_new_rows,
854 phase: super::RecursiveStatsPhase::Phase2Delta,
855 binary_est_for_variant,
856 });
857 }
858
859 // Fixpoint reached if no deltas produced.
860 if delta_tracker.is_converged() {
861 reached_fixpoint = true;
862 self.profiler.record_iterations(iteration_count);
863 break;
864 }
865
866 // Merge deltas into full relations.
867 for pred in &recursive_preds {
868 let dn = delta_tracker.delta_name(pred)?.to_string();
869 // Read both relations in place: the store is only mutated
870 // after the merge succeeds, so a failed union leaves the
871 // full relation (and its version counter) intact, and the
872 // unchanged delta never needs a remove/re-put round trip.
873 let full_old = self
874 .store
875 .get(pred)
876 .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", pred)))?;
877 let delta = self
878 .store
879 .get(&dn)
880 .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", dn)))?;
881
882 if self.buffer_row_count(delta)? == 0 {
883 // Zero-delta short-circuit: full and delta are unchanged
884 // this iteration. The delta relation record with zero
885 // rows stands, and the full relation record from the prior
886 // merge stands. No additional update is needed.
887 continue;
888 }
889
890 let union_input = full_old.num_rows() + delta.num_rows();
891 let start = self.profiler.start_op();
892 let merged = self.provider.union_gpu(full_old, delta)?;
893 if let Some(start) = start {
894 let mem = self.provider.memory().allocated_bytes();
895 self.profiler
896 .record_op("union", union_input, merged.num_rows(), start, mem);
897 self.profiler.record_peak_memory(mem);
898 }
899
900 let full_new = merged;
901 // Capture `full_new`'s row count before the `store_put` move
902 // and pre-resolve `full_rel_opt` before the mutable stats
903 // borrow. The delta row count and delta relation id are only
904 // used by the trace under the `recursive-stats-trace` feature.
905 let full_new_rows_phase4 = self.buffer_row_count(&full_new)? as u64;
906 #[cfg(feature = "recursive-stats-trace")]
907 let delta_rows_phase4 = self.buffer_row_count(delta)? as u64;
908 let full_rel_opt = self.name_to_rel_id(pred);
909 #[cfg(feature = "recursive-stats-trace")]
910 let delta_rel = delta_tracker.delta_rel_id(pred)?;
911 self.store_put(pred, full_new);
912
913 // Record the full relation's new cardinality. The delta
914 // relation was already recorded for this iteration.
915 if let Some(full_rel) = full_rel_opt {
916 self.stats
917 .update_cardinality(full_rel, full_new_rows_phase4);
918 }
919 self.refresh_kclique_edge_metadata_after_merge(rules, pred);
920
921 // Full-relation stats trace entry, gated on `recursive-stats-trace`.
922 #[cfg(feature = "recursive-stats-trace")]
923 self.last_recursive_stats_trace
924 .entries
925 .push(super::RecursiveStatsTraceEntry {
926 iteration: iteration_count,
927 pred: pred.clone(),
928 full_rel: full_rel_opt.unwrap_or(RelId(u32::MAX)),
929 delta_rel,
930 full_rows: full_new_rows_phase4,
931 delta_rows: delta_rows_phase4,
932 phase: super::RecursiveStatsPhase::Phase4Full,
933 binary_est_for_variant: None,
934 });
935 }
936 }
937
938 // Cleanup: remove delta relations from store and relation mapping.
939 for (_pred, (rel_id, delta_name)) in delta_tracker.into_inner() {
940 self.store_remove(&delta_name);
941 self.rel_names.remove(&rel_id);
942 self.name_to_rel.remove(&delta_name);
943 let _ = self.stats.unregister_relation(rel_id);
944 }
945
946 if !reached_fixpoint {
947 // Record iterations even on failure for debugging
948 self.profiler.record_iterations(iteration_count);
949 return Err(XlogError::Execution(format!(
950 "Recursive SCC iteration limit ({}) exceeded",
951 self.config.max_iterations
952 )));
953 }
954
955 Ok(())
956 }
957
958 /// Execute a Fixpoint node using semi-naive evaluation
959 ///
960 /// The semi-naive algorithm avoids redundant computation in recursive queries:
961 ///
962 /// 1. **Initialize:**
963 /// - Compute base case: `R = base_result`
964 /// - Set delta to base: `delta = R`
965 /// - Store both `R` and `delta` in RelationStore
966 ///
967 /// 2. **Iterate until fixpoint:**
968 /// - Compute new tuples: `delta_new = recursive_result` using current `delta`
969 /// - Remove already-known tuples: `delta_new = delta_new - R`
970 /// - If `delta_new` is empty, we have reached fixpoint
971 /// - Otherwise: `R = R union delta_new`, `delta = delta_new`
972 ///
973 /// 3. **Return:** Final `R`
974 ///
975 /// # Arguments
976 /// * `scc_id` - SCC identifier for logging/debugging
977 /// * `base` - Base case RIR tree (non-recursive facts/rules)
978 /// * `recursive` - Recursive RIR tree (references delta relation)
979 /// * `delta_rel` - RelId for delta relation
980 /// * `full_rel` - RelId for full relation
981 ///
982 /// # Returns
983 /// A CudaBuffer containing the final fixpoint result
984 ///
985 /// # Errors
986 /// Returns an error if iteration limit is exceeded
987 pub(super) fn execute_fixpoint(
988 &mut self,
989 scc_id: u32,
990 base: &RirNode,
991 recursive: &RirNode,
992 delta_rel: RelId,
993 full_rel: RelId,
994 ) -> Result<CudaBuffer> {
995 // Compute base case R = eval(base)
996 let r_initial = self.execute_node(base)?;
997
998 // Handle empty base case using device-resident row count
999 if self.buffer_row_count(&r_initial)? == 0 {
1000 return Ok(r_initial);
1001 }
1002
1003 // Initialize delta = R (clone the base result)
1004 let delta_initial = self.clone_buffer(&r_initial)?;
1005
1006 // Get relation names for delta and full relations
1007 let delta_name = self.get_or_create_rel_name(delta_rel, &format!("__delta_{}", scc_id));
1008 let full_name = self.get_or_create_rel_name(full_rel, &format!("__full_{}", scc_id));
1009
1010 // Store initial R and delta in relation store
1011 self.store_put(&full_name, r_initial);
1012 self.store_put(&delta_name, delta_initial);
1013
1014 // Iterate until fixpoint
1015 let max_iterations = self.config.max_iterations as usize;
1016 for _iteration in 0..max_iterations {
1017 // Evaluate recursive step using current delta
1018 // The recursive RIR tree should reference delta_rel internally
1019 let delta_new_raw = self.execute_node(recursive)?;
1020
1021 // Get current R for set difference
1022 let current_r = self.store.get(&full_name).ok_or_else(|| {
1023 XlogError::Execution(format!(
1024 "Full relation {} not found during fixpoint iteration",
1025 full_name
1026 ))
1027 })?;
1028
1029 // Compute delta_new = delta_new_raw - R (remove already-known tuples)
1030 let delta_new = self.provider.diff_gpu(&delta_new_raw, current_r)?;
1031
1032 // Check for fixpoint: if delta_new is empty, we are done
1033 if self.buffer_row_count(&delta_new)? == 0 {
1034 // Fixpoint reached - return final R
1035 let final_r = self.store_remove(&full_name).ok_or_else(|| {
1036 XlogError::Execution("Full relation lost during fixpoint".to_string())
1037 })?;
1038
1039 // Clean up delta relation
1040 self.store_remove(&delta_name);
1041
1042 return Ok(final_r);
1043 }
1044
1045 // Not at fixpoint yet: R = R union delta_new
1046 let new_r = self.provider.union_gpu(current_r, &delta_new)?;
1047
1048 // Update relations for next iteration
1049 // delta = delta_new (the newly discovered tuples)
1050 self.store_put(&delta_name, delta_new);
1051 self.store_put(&full_name, new_r);
1052 }
1053
1054 // Iteration limit exceeded
1055 Err(XlogError::Execution(format!(
1056 "Fixpoint iteration limit ({}) exceeded for SCC {}",
1057 max_iterations, scc_id
1058 )))
1059 }
1060}