Skip to main content

xlog_integration/
wcoj_dispatch.rs

1//! v0.6.2 minimal env-gated GPU 3-way WCOJ triangle dispatch.
2//!
3//! Single public entry:
4//! [`try_wcoj_triangle_u32_dispatch`] (env-driven) and
5//! [`try_wcoj_triangle_u32_dispatch_with_gate`] (boolean-driven,
6//! for tests). The slice is intentionally narrow:
7//!
8//!   * **Env flag only.** `XLOG_USE_WCOJ_TRIANGLE_U32=1` (or
9//!     `true`/`TRUE`) opts in. Anything else (unset, `0`,
10//!     `false`, etc.) means the helper returns `Ok(None)`
11//!     unconditionally — the caller takes the existing
12//!     binary-join path.
13//!   * **Recognizes exactly one shape.** A rule of the form
14//!     `tri(X, Y, Z) :- e1(X, Y), e2(Y, Z), e3(X, Z)` over
15//!     2-column WCOJ-eligible relations (U32, Symbol, or U64
16//!     keys — see `WcojKeyWidth`): three positive 2-arity body
17//!     atoms covering the head's three distinct variables in
18//!     head-position order. No negation, no comparison filters,
19//!     no recursion (head predicate not in body), no
20//!     reversed-axis atoms (e.g. `e1(Y, X)`), no constants in
21//!     atom args. The planner must also return
22//!     [`xlog_logic::hypergraph::RulePlan::MultiwayCandidate`].
23//!   * **Width uniformity.** All three slots must share a key
24//!     width. A mixed-width triangle (e.g. e1 U32, e2 U64) is
25//!     rejected at this dispatch level — the binary-join chain
26//!     handles it.
27//!   * **Silent fallback.** Any mismatch — gate off, shape
28//!     mismatch, planner verdict not multiway, missing input
29//!     buffer, unsupported scalar type, mixed-width slots —
30//!     returns `Ok(None)` without an error or log line. The
31//!     caller is expected to silently route to the existing
32//!     binary-join path. This keeps the env flag truly opt-in
33//!     and prevents the helper from accidentally diverting
34//!     work it can't handle.
35//!   * **Strict GPU pipeline on dispatch.** When all checks pass,
36//!     the helper builds three sorted+deduped layouts and runs
37//!     the matching WCOJ triangle kernel on the configured
38//!     `launch_stream` — `wcoj_layout_u32_recorded` /
39//!     `wcoj_triangle_u32_recorded` for 4-byte keys, the
40//!     `_u64_recorded` siblings for 8-byte keys. All
41//!     [`xlog_cuda::launch::LaunchRecorder`] discipline carries
42//!     through unchanged.
43//!
44//! What this slice deliberately does NOT do:
45//!
46//!   * No automatic detection at the executor level — callers
47//!     pass the rule + input buffers explicitly. Executor
48//!     wiring lives in `xlog-runtime`.
49//!   * No recursion / SCC mixed execution.
50//!   * No cost model.
51//!   * No mixed-width admission (U32+U64 triangle stays on the
52//!     binary-join path).
53//!   * No histogram-guided block dispatch.
54
55use std::collections::{BTreeMap, BTreeSet};
56
57use xlog_core::{resolve_bool, Result, ScalarType};
58use xlog_cuda::device_runtime::StreamId;
59use xlog_cuda::memory::CudaBuffer;
60use xlog_cuda::CudaKernelProvider;
61use xlog_logic::ast::{BodyLiteral, Rule, Term};
62use xlog_logic::hypergraph::{plan_rule, RefRelation, RefRelationStore, RefValue, RulePlan};
63
64/// Env variable controlling the dispatch gate. Treated as ON
65/// when set to `"1"` or case-insensitive `"true"`; anything else
66/// (unset, `"0"`, `"false"`, empty string, …) means OFF.
67pub const ENV_USE_WCOJ_TRIANGLE_U32: &str = "XLOG_USE_WCOJ_TRIANGLE_U32";
68
69/// Env-driven entry. Reads `XLOG_USE_WCOJ_TRIANGLE_U32` and
70/// delegates to [`try_wcoj_triangle_u32_dispatch_with_gate`].
71///
72/// Tests should prefer [`try_wcoj_triangle_u32_dispatch_with_gate`]
73/// to avoid races on the process-global env var; the env-driven
74/// form exists for production callers that want to opt in via
75/// configuration alone.
76pub fn try_wcoj_triangle_u32_dispatch(
77    rule: &Rule,
78    inputs: &BTreeMap<String, CudaBuffer>,
79    provider: &CudaKernelProvider,
80    launch_stream: StreamId,
81) -> Result<Option<CudaBuffer>> {
82    let gate = resolve_bool(None, ENV_USE_WCOJ_TRIANGLE_U32, false)?;
83    try_wcoj_triangle_u32_dispatch_with_gate(gate, rule, inputs, provider, launch_stream)
84}
85
86/// Test-friendly form that takes the gate as an explicit boolean.
87/// Production callers use [`try_wcoj_triangle_u32_dispatch`] which
88/// reads the env var.
89pub fn try_wcoj_triangle_u32_dispatch_with_gate(
90    gate_enabled: bool,
91    rule: &Rule,
92    inputs: &BTreeMap<String, CudaBuffer>,
93    provider: &CudaKernelProvider,
94    launch_stream: StreamId,
95) -> Result<Option<CudaBuffer>> {
96    if !gate_enabled {
97        return Ok(None);
98    }
99    // Match the triangle shape — silently bail on any mismatch.
100    let Some(matched) = match_triangle_shape(rule, inputs) else {
101        return Ok(None);
102    };
103
104    // Run the planner on a synthetic store carrying the inputs'
105    // schemas. plan_rule needs schemas (not rows) for typed
106    // gating; rows are not consulted at the gate phase.
107    let mut plan_store: RefRelationStore = BTreeMap::new();
108    for atom_match in &matched.atoms {
109        // Skip duplicates: the same predicate may legitimately
110        // appear in multiple slots (e.g. all three atoms over a
111        // single edge relation), and a BTreeMap insert collapses
112        // them naturally. Schemas are guaranteed identical
113        // because they all came from the same `inputs[name]`.
114        plan_store.insert(
115            atom_match.predicate.clone(),
116            schema_only_relation(&inputs[&atom_match.predicate]),
117        );
118    }
119    let plan = plan_rule(rule, &plan_store).ok();
120    match plan {
121        Some(RulePlan::MultiwayCandidate { .. }) => {}
122        _ => return Ok(None),
123    }
124
125    // Construct sorted+deduped layouts for each slot and run
126    // the WCOJ triangle kernel. Branch by width: 4-byte
127    // (U32 / Symbol) and 8-byte (U64) inputs go to parallel
128    // provider entries. Mixed-width triangles never reach this
129    // point — `match_triangle_shape` requires all three slots
130    // to share a width.
131    let result = match matched.width {
132        WcojKeyWidth::FourByte => {
133            let buf_xy = provider.wcoj_layout_u32_recorded(matched.e_xy, launch_stream)?;
134            let buf_yz = provider.wcoj_layout_u32_recorded(matched.e_yz, launch_stream)?;
135            let buf_xz = provider.wcoj_layout_u32_recorded(matched.e_xz, launch_stream)?;
136            provider.wcoj_triangle_u32_recorded(&buf_xy, &buf_yz, &buf_xz, launch_stream)?
137        }
138        WcojKeyWidth::EightByte => {
139            let buf_xy = provider.wcoj_layout_u64_recorded(matched.e_xy, launch_stream)?;
140            let buf_yz = provider.wcoj_layout_u64_recorded(matched.e_yz, launch_stream)?;
141            let buf_xz = provider.wcoj_layout_u64_recorded(matched.e_xz, launch_stream)?;
142            provider.wcoj_triangle_u64_recorded(&buf_xy, &buf_yz, &buf_xz, launch_stream)?
143        }
144    };
145    Ok(Some(result))
146}
147
148/// Matched atom slots after pattern recognition. Each `&CudaBuffer`
149/// borrows from the caller's `inputs` map. `width` is the key
150/// width shared by all three slots (mixed-width triangles never
151/// reach this struct — see `match_triangle_shape`).
152struct MatchedAtoms<'a> {
153    atoms: Vec<MatchedAtom>,
154    e_xy: &'a CudaBuffer,
155    e_yz: &'a CudaBuffer,
156    e_xz: &'a CudaBuffer,
157    width: WcojKeyWidth,
158}
159
160struct MatchedAtom {
161    predicate: String,
162}
163
164/// Physical key width for a WCOJ-eligible binary relation.
165/// `FourByte` covers `U32` and `Symbol` (bit-identical layout);
166/// `EightByte` covers `U64`. Other scalar types are not
167/// WCOJ-eligible at this dispatch level (the planner upstream
168/// is the source of truth for cross-relation type compatibility;
169/// `analyze_typed` rejects them before we get here).
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum WcojKeyWidth {
172    FourByte,
173    EightByte,
174}
175
176/// Attempt to match the rule + inputs against the v1 triangle
177/// shape. Returns `Some(...)` if every check passes; `None`
178/// otherwise. All checks are pure validation — no buffer
179/// modification, no kernel launches, no errors.
180fn match_triangle_shape<'a>(
181    rule: &Rule,
182    inputs: &'a BTreeMap<String, CudaBuffer>,
183) -> Option<MatchedAtoms<'a>> {
184    // Head must have exactly 3 distinct variable terms.
185    let head_vars = head_vars_or_none(rule)?;
186
187    // Body must have exactly 3 BodyLiteral::Positive atoms; no
188    // negation, no comparison filters, no is-expr.
189    if rule.body.len() != 3 {
190        return None;
191    }
192    let mut positive_atoms = Vec::with_capacity(3);
193    for literal in &rule.body {
194        match literal {
195            BodyLiteral::Positive(atom) => positive_atoms.push(atom),
196            _ => return None,
197        }
198    }
199
200    // Head predicate must NOT appear in the body — rules out
201    // direct recursion. Indirect recursion through SCC structure
202    // is also unsupported in this slice; since we only see one
203    // rule at a time, "head predicate equals any body
204    // predicate" is the conservative check.
205    let head_predicate = &rule.head.predicate;
206    for atom in &positive_atoms {
207        if &atom.predicate == head_predicate {
208            return None;
209        }
210    }
211
212    // Each atom must be 2-arity, with both args being head
213    // variables (not constants, anonymous, or non-head vars).
214    // Collect (head_position_0, head_position_1) per atom; the
215    // pair must be in ascending order — i.e. atom args are in
216    // head-position order, not reversed.
217    //
218    // After this collection, the three atoms together must
219    // cover EXACTLY the three pairs {0,1}, {1,2}, {0,2} (the
220    // triangle's three edges over head positions).
221    let mut atom_specs: Vec<(usize, usize, &xlog_logic::ast::Atom)> = Vec::with_capacity(3);
222    for atom in &positive_atoms {
223        if atom.terms.len() != 2 {
224            return None;
225        }
226        let pos0 = head_pos_of_var(&head_vars, &atom.terms[0])?;
227        let pos1 = head_pos_of_var(&head_vars, &atom.terms[1])?;
228        if pos0 >= pos1 {
229            // Same variable twice (`e(X, X)`) or reversed
230            // axis (`e(Y, X)`). Both are out-of-scope for v1
231            // dispatch.
232            return None;
233        }
234        atom_specs.push((pos0, pos1, atom));
235    }
236    let pair_set: BTreeSet<(usize, usize)> = atom_specs.iter().map(|(a, b, _)| (*a, *b)).collect();
237    let mut expected: BTreeSet<(usize, usize)> = BTreeSet::new();
238    expected.insert((0, 1));
239    expected.insert((1, 2));
240    expected.insert((0, 2));
241    if pair_set != expected {
242        return None;
243    }
244
245    // Look up each input buffer by predicate name. Validate
246    // every buffer is a 2-column WCOJ-eligible relation
247    // (4-byte U32/Symbol or 8-byte U64) AND that all three
248    // slots share the same width — mixed-width triangles fall
249    // back here so the binary-join chain handles them.
250    let mut e_xy: Option<&CudaBuffer> = None;
251    let mut e_yz: Option<&CudaBuffer> = None;
252    let mut e_xz: Option<&CudaBuffer> = None;
253    let mut atoms_out: Vec<MatchedAtom> = Vec::with_capacity(3);
254    let mut shared_width: Option<WcojKeyWidth> = None;
255    for (pos0, pos1, atom) in &atom_specs {
256        let buf = inputs.get(&atom.predicate)?;
257        let w = classify_two_col_wcoj_width(buf)?;
258        match shared_width {
259            None => shared_width = Some(w),
260            Some(prev) if prev == w => {}
261            Some(_) => return None,
262        }
263        atoms_out.push(MatchedAtom {
264            predicate: atom.predicate.clone(),
265        });
266        match (*pos0, *pos1) {
267            (0, 1) => e_xy = Some(buf),
268            (1, 2) => e_yz = Some(buf),
269            (0, 2) => e_xz = Some(buf),
270            _ => unreachable!("pair_set check above guarantees these three"),
271        }
272    }
273    Some(MatchedAtoms {
274        atoms: atoms_out,
275        e_xy: e_xy?,
276        e_yz: e_yz?,
277        e_xz: e_xz?,
278        width: shared_width?,
279    })
280}
281
282/// Return the rule head's variable names if every head term is a
283/// distinct `Term::Variable`; `None` otherwise.
284fn head_vars_or_none(rule: &Rule) -> Option<Vec<String>> {
285    if rule.head.terms.len() != 3 {
286        return None;
287    }
288    let mut out = Vec::with_capacity(3);
289    for term in &rule.head.terms {
290        match term {
291            Term::Variable(name) => out.push(name.clone()),
292            _ => return None,
293        }
294    }
295    // Distinctness.
296    let unique: BTreeSet<&String> = out.iter().collect();
297    if unique.len() != 3 {
298        return None;
299    }
300    Some(out)
301}
302
303/// Map an atom-arg term to its position in the head variable list
304/// (0, 1, or 2). Returns `None` for anything that isn't a
305/// head-list variable.
306fn head_pos_of_var(head_vars: &[String], term: &Term) -> Option<usize> {
307    match term {
308        Term::Variable(name) => head_vars.iter().position(|v| v == name),
309        _ => None,
310    }
311}
312
313/// Classify a binary [`CudaBuffer`]'s key width for WCOJ
314/// dispatch. Returns
315///
316/// * `Some(WcojKeyWidth::FourByte)` when both columns are
317///   `U32` or `Symbol` (bit-identical 4-byte physical layout —
318///   the WCOJ kernel reads them identically),
319/// * `Some(WcojKeyWidth::EightByte)` when both columns are
320///   `U64`,
321/// * `None` for any other arity / type combination, including
322///   mixed widths within a single buffer (e.g. one column U32
323///   and the other U64).
324///
325/// Cross-relation type-compatibility (so a Symbol column never
326/// joins with a U32 column with the same bit pattern, and a
327/// U32 column never joins with a U64 column) is enforced by
328/// the planner upstream via `xlog_logic::hypergraph::analyze_typed`;
329/// the dispatch helper additionally requires width-uniformity
330/// across the three slots in `match_triangle_shape`.
331fn classify_two_col_wcoj_width(buf: &CudaBuffer) -> Option<WcojKeyWidth> {
332    if buf.arity() != 2 {
333        return None;
334    }
335    let c0 = buf.schema().column_type(0)?;
336    let c1 = buf.schema().column_type(1)?;
337    let width0 = scalar_wcoj_width(c0)?;
338    let width1 = scalar_wcoj_width(c1)?;
339    if width0 != width1 {
340        return None;
341    }
342    Some(width0)
343}
344
345/// Map a single [`ScalarType`] to its WCOJ key width, or `None`
346/// if the type is not supported by any current WCOJ entry.
347fn scalar_wcoj_width(ty: ScalarType) -> Option<WcojKeyWidth> {
348    match ty {
349        ScalarType::U32 | ScalarType::Symbol => Some(WcojKeyWidth::FourByte),
350        ScalarType::U64 => Some(WcojKeyWidth::EightByte),
351        _ => None,
352    }
353}
354
355/// Build a synthetic schema-only [`RefRelation`] for the planner's
356/// view. Rows are intentionally empty: `plan_rule` only consults
357/// schemas for typed gating, never rows.
358fn schema_only_relation(buf: &CudaBuffer) -> RefRelation {
359    let arity = buf.arity();
360    let schema: Vec<ScalarType> = (0..arity)
361        .map(|i| buf.schema().column_type(i).unwrap_or(ScalarType::U32))
362        .collect();
363    RefRelation {
364        schema,
365        rows: Vec::<Vec<RefValue>>::new(),
366    }
367}