polydat_core/compile/cone.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cone-level JIT inside the interpreter kernel (engines.md §2, §8).
5//!
6//! At assembly time, maximal cones of JIT-eligible nodes with
7//! scalar boundaries collapse into one synthetic `JitConeNode`
8//! each, compiled to native code by the P3 codegen. The
9//! cone node is an ordinary `PolydatNode`: the walker, scope
10//! chains, shared cells, None propagation, node_clean caching, and
11//! the enrich-and-re-raise panic contract all see a plain node.
12//!
13//! Boundary marshalling covers every one-slot immediate and every
14//! `Ref2` kind, borrowed into its pair for the call and copied out
15//! after it; interior fusion follows whatever the P3 classifier
16//! accepts. Extraction is recoverable:
17//! member nodes move into the cone only after codegen succeeds, so
18//! a cone whose code generation fails leaves its members exactly as
19//! the interpreter would have compiled them. Under `JitMode::Auto` the
20//! failure is recorded on the tree's `CompileLedger` and the compile
21//! goes on; under `JitMode::Force` it fails the compile (engines.md
22//! §2.1).
23//!
24//! A component that is not convex is first split into the convex pieces
25//! native code and pure native code form from it
26//! (`fusion_units::convex_pieces`, engines.md §8). A cone reads at most
27//! [`MAX_CONE_INPUTS`] distinct boundary inputs, and a convex piece that
28//! reads more is cut into pieces within the bound, each compiled on its
29//! own (engines.md §2.2).
30
31/// The most distinct boundary inputs one cone piece reads: an
32/// implementation bound on each piece, not on the component it is cut
33/// from. A component over it is cut into pieces within it, and a single
34/// node over it stays on the interpreter and is recorded on the ledger
35/// (engines.md §2.2).
36pub const MAX_CONE_INPUTS: usize = 64;
37
38/// How much of the interpreter's graph is fused into native cones: the
39/// interpreter engine's one knob, carried by
40/// [`Engine::Interpreter`](crate::Engine::Interpreter) and settable per
41/// assembler with `set_jit_mode`. It is a property of the kernel being
42/// built, never of the process: two hosts in one process compiling
43/// under different modes get the kernels they each asked for.
44#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
45pub enum JitMode {
46 /// Pure interpreter, no native code: the differential baseline.
47 Off,
48 /// Cone extraction with the cost model (fused cones of >= 2 nodes):
49 /// what a host gets when it names none.
50 #[default]
51 Auto,
52 /// Every eligible node joins a cone (threshold 1). Used by the
53 /// differential battery and for isolating marshalling regressions.
54 Force,
55}
56
57#[cfg(not(feature = "jit"))]
58pub(crate) fn extract_jit_cones(
59 _dag: &mut super::assembly::ResolvedDag,
60 _mode: JitMode,
61) -> Result<(), super::assembly::AssemblyError> {
62 Ok(())
63}
64
65#[cfg(feature = "jit")]
66pub(crate) use jit_impl::extract_jit_cones;
67
68#[cfg(feature = "jit")]
69mod jit_impl {
70 use super::{JitMode, MAX_CONE_INPUTS};
71 use crate::ast::{NodeMeta, PolydatNode, Port, PortType, Purity, Slot, SlotShape, Value};
72 use crate::compile::assembly::{AssemblyError, PolydatAssembler, ResolvedDag};
73 use crate::compile::jit::{JitOp, classify_node_typed};
74 use crate::kernel::{ConeFallback, ConeFallbackKind};
75 use crate::kernel::{InputDef, InputKind, WireSource};
76 use std::collections::HashMap;
77
78 /// A fused subgraph compiled to native code, standing in the
79 /// program as one ordinary node (engines.md §2). The node is shared by
80 /// every state of the program; the slot buffer its native code
81 /// runs over, and the scratch entries its members' kits write
82 /// into, belong to the state that evaluates it, which hands them
83 /// in through [`PolydatNode::eval_in`] (axiom S3).
84 pub(crate) struct JitConeNode {
85 meta: NodeMeta,
86 code_fn: crate::compile::jit::NativeFn,
87 total_slots: usize,
88 /// The members' scratch entries, after the slot buffer in the
89 /// cone's scratch layout, with the validator's pairs.
90 scratch: crate::compile::jit::ScratchPlan,
91 /// Where each member lives, for the failure path (engines.md §3.4): the
92 /// member that failed is named as the program names it, with
93 /// its outputs under the program's names; the cone is no frame.
94 attribution: std::sync::Arc<crate::compile::Attribution>,
95 /// First buffer slot per boundary input, in port order.
96 in_slots: Vec<usize>,
97 /// Buffer slot per output port, in `meta.outs` order.
98 out_slots: Vec<usize>,
99 in_types: Vec<PortType>,
100 out_types: Vec<PortType>,
101 /// The original member nodes — kept alive for the LUT /
102 /// constant memory the native code references, and walked
103 /// by identity hashing (`fusion_subgraph`).
104 members: Vec<Box<dyn PolydatNode>>,
105 /// Local member wiring (`Input(i)` = this node's i-th
106 /// outer input; `NodeOutput(j, p)` = member j) — the
107 /// stored subgraph identity hashing recurses through.
108 sub_wiring: Vec<Vec<WireSource>>,
109 /// Per output port: (local member index, member port).
110 out_ports: Vec<(usize, usize)>,
111 /// The finalized code and the kits it calls, kept alive for
112 /// the life of the program.
113 _module: crate::compile::jit::JitCode,
114 /// Whether the code calls a helper, and so runs under the
115 /// catch; code with no call runs bare.
116 fallible: bool,
117 }
118
119 impl PolydatNode for JitConeNode {
120 fn meta(&self) -> &NodeMeta {
121 &self.meta
122 }
123
124 fn fusion_subgraph(&self) -> Option<crate::ast::FusionSubgraph<'_>> {
125 Some(crate::ast::FusionSubgraph {
126 members: &self.members,
127 wiring: &self.sub_wiring,
128 out_ports: &self.out_ports,
129 })
130 }
131
132 /// The state owns the cone's slot buffer and its members'
133 /// scratch entries (axiom S3): one `Slots` entry, then the
134 /// entries the members' kits declared, handed in at every
135 /// evaluation.
136 fn scratch_layout(&self) -> Vec<crate::ast::ScratchElem> {
137 let mut layout = vec![crate::ast::ScratchElem::Slots];
138 layout.extend(self.scratch.elems.iter().copied());
139 layout
140 }
141
142 fn eval_in(
143 &self,
144 scratch: &mut [crate::ast::ScratchBuf],
145 inputs: &[Value],
146 outputs: &mut [Value],
147 ) {
148 let (slots, members) = scratch.split_at_mut(1);
149 let crate::ast::ScratchBuf::Slots(buf) = &mut slots[0] else {
150 unreachable!("a cone's scratch is its slot buffer");
151 };
152 self.eval_with(buf, members, inputs, outputs)
153 }
154
155 /// An evaluation without a state's scratch (a node evaluated
156 /// on its own): a buffer and entries of the call's own.
157 fn eval(&self, inputs: &[Value], outputs: &mut [Value]) {
158 let mut buf = Vec::new();
159 let mut members: Vec<crate::ast::ScratchBuf> = self
160 .scratch
161 .elems
162 .iter()
163 .map(|e| crate::ast::ScratchBuf::new(*e))
164 .collect();
165 self.eval_with(&mut buf, &mut members, inputs, outputs)
166 }
167 }
168
169 impl JitConeNode {
170 /// Evaluate over `buf` and the members' scratch: the boundary
171 /// inputs are borrowed into their slots for the duration of the
172 /// call, the native code runs, and every output is copied out
173 /// as an owned `Value` (the interpreter never holds a reference
174 /// into a buffer).
175 fn eval_with(
176 &self,
177 buf: &mut Vec<u64>,
178 members: &mut [crate::ast::ScratchBuf],
179 inputs: &[Value],
180 outputs: &mut [Value],
181 ) {
182 buf.clear();
183 buf.resize(self.total_slots + 1, 0);
184 for (i, v) in inputs.iter().enumerate() {
185 let start = self.in_slots[i];
186 if crate::compile::marshal::encode_slots(v, self.in_types[i], &mut buf[start..])
187 .is_none()
188 {
189 panic!(
190 "cone `{}` boundary input [{i}] expected {:?}, got {:?}",
191 self.meta.name,
192 self.in_types[i],
193 v.port_type()
194 );
195 }
196 }
197 // Native code names the member it is in before each helper
198 // call (the slot past the layout); a failure is re-raised
199 // attributed to that member with the program's context and
200 // output names, and the interpreter re-raises it as is
201 // (engines.md §3.4).
202 let code_fn = self.code_fn;
203 let cp = buf.as_ptr();
204 let mp = buf.as_mut_ptr();
205 let sc = members.as_mut_ptr();
206 if !self.fallible {
207 // Code that calls no helper cannot fail: it runs bare.
208 unsafe { (code_fn)(cp, mp, sc) };
209 } else {
210 buf[self.total_slots] = u64::MAX;
211 let capture = crate::kernel::engines::EvalPanicCaptureGuard::arm();
212 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
213 crate::compile::jit::invoke_with_catch(move || unsafe {
214 (code_fn)(cp, mp, sc);
215 })
216 }));
217 drop(capture);
218 if let Err(payload) = outcome {
219 let step = buf[self.total_slots] as usize;
220 self.attribution.reraise(payload, step, buf, None);
221 }
222 }
223 #[cfg(debug_assertions)]
224 for &(slot, idx) in &self.scratch.refs {
225 let (p, l) = members[idx].ptr_len();
226 assert!(
227 buf[slot] == p && buf[slot + 1] == l,
228 "S9 ref-validator: cone `{}` slot pair ({slot}, {}) does not name \
229 scratch[{idx}]",
230 self.meta.name,
231 slot + 1
232 );
233 }
234 for (k, slot) in self.out_slots.iter().enumerate() {
235 outputs[k] = crate::compile::marshal::decode_output(buf, *slot, self.out_types[k]);
236 }
237 }
238 }
239
240 /// A planned-but-rejected cone is diagnosable state, never
241 /// silent (audit channel, Debug level — rejections are normal
242 /// cost-model outcomes, not user-facing failures).
243 fn audit_skip(member_count: usize, reason: &str) {
244 crate::library::support::audit::debug(&format!(
245 "jit cone: leaving a {member_count}-member component on the interpreter: {reason}"
246 ));
247 }
248
249 /// Marshalable boundary types: every one-slot immediate, encoded
250 /// as the bits its `Wire` impl injects (a signed narrow carrier
251 /// sign-extended, an unsigned or float one as its bits;
252 /// type_system_alignment.md §2), and every `Ref2` kind, borrowed
253 /// into its pair for the call and copied out after it
254 /// (compiled_handles.md §4). The 128-bit immediates stay out until
255 /// they have a boundary encoding of their own.
256 fn scalar_ok(ty: PortType) -> bool {
257 use crate::ast::SlotColor;
258 match ty.slot_color() {
259 SlotColor::Imm1 | SlotColor::Ref2 => true,
260 SlotColor::Imm2 => false,
261 }
262 }
263
264 /// A node may join a cone iff the P3 classifier can lower it with
265 /// its wire types known, it is pure, and every wire port is a
266 /// single-slot value this push can marshal. The None rule
267 /// (engines.md §3.3)
268 /// is applied by the caller, which knows where each input comes
269 /// from.
270 fn node_eligible(node: &dyn PolydatNode, wire_types: &[PortType]) -> bool {
271 matches!(node.purity(), Purity::Pure)
272 && !matches!(classify_node_typed(node, wire_types), JitOp::Fallback)
273 && node.meta().outs.iter().all(|p| scalar_ok(p.typ))
274 && wire_types.iter().all(|t| scalar_ok(*t))
275 && node.meta().wire_inputs().iter().all(|p| scalar_ok(p.typ))
276 }
277
278 /// The hoisting classes (compile-constant, scope-init, dynamic;
279 /// graph_compiler.md §3), read from the one
280 /// classifier the program carries, so that extraction can
281 /// restrict fusion to per-cycle work. Const and scope-init
282 /// subgraphs belong to the fold passes (which evaluate them
283 /// once); fusing them would demote them to per-pull native
284 /// evaluation and — for multi-output cones — block
285 /// `fold_init_constants`' single-output replacement, breaking
286 /// `get_constant` consumers like `eval_const_expr`.
287 ///
288 /// Reading the program's classifier, rather than walking the graph
289 /// again, keeps the `volatile` output modifier and volatility's
290 /// downstream propagation in the answer, so a node a program
291 /// declares volatile never reads here as const and is never fused
292 /// into a cone the fold evaluates once.
293 ///
294 /// Returns each node's lifecycle and whether it is volatile.
295 fn classify_lifecycles(dag: &ResolvedDag) -> (Vec<crate::kernel::EvalLifecycle>, Vec<bool>) {
296 let classes = crate::kernel::PolydatProgram::classify_lifecycle(
297 &dag.nodes,
298 &dag.wiring,
299 &dag.input_defs,
300 &dag.output_map,
301 &dag.output_modifiers,
302 );
303 (classes.lifecycle, classes.nondeterministic)
304 }
305
306 /// Dedup/lookup key for a boundary wire source.
307 fn src_key(src: &WireSource) -> (u8, usize, usize) {
308 match src {
309 WireSource::Input(i) => (0, *i, 0),
310 WireSource::NodeOutput(j, p) => (1, *j, *p),
311 }
312 }
313
314 struct ConePlan {
315 /// Member node indices, ascending (inherits topo order).
316 members: Vec<usize>,
317 /// Boundary input sources, deduped, in first-use order.
318 boundary_in: Vec<WireSource>,
319 in_types: Vec<PortType>,
320 /// Boundary output ports `(member_idx, port)`, first-use order.
321 boundary_out: Vec<(usize, usize)>,
322 out_types: Vec<PortType>,
323 }
324
325 /// Replace eligible cones in `dag` with compiled cone nodes.
326 ///
327 /// A cone whose code generation fails keeps its members as
328 /// interpreter nodes. Under `Auto` the failure is recorded on the
329 /// tree's ledger and the DAG is left valid and topologically sorted;
330 /// under `Force` it is returned as [`AssemblyError::NativeCone`]
331 /// (engines.md §2.1). A node that alone reads more than
332 /// [`MAX_CONE_INPUTS`] boundary inputs stays on the interpreter under
333 /// either mode and is recorded (engines.md §2.2).
334 pub(crate) fn extract_jit_cones(
335 dag: &mut ResolvedDag,
336 mode: JitMode,
337 ) -> Result<(), AssemblyError> {
338 let min_members = match mode {
339 JitMode::Off => return Ok(()),
340 JitMode::Auto => 2,
341 JitMode::Force => 1,
342 };
343 let n = dag.nodes.len();
344 if n == 0 {
345 return Ok(());
346 }
347
348 let (lifecycles, volatile) = classify_lifecycles(dag);
349 // Eligibility in topological order, because the None rule
350 // (engines.md §3.3) for a None-tolerant node depends on its sources: the
351 // kernel guard makes a fused cone None whenever a boundary
352 // input is None, so a node that would have seen the None and
353 // produced a value (`tile_encode` writes `null`, `to_json`
354 // keeps going) may join only when every input is an intra-cone
355 // wire from an eligible node, where no None can arrive. Every
356 // other node is guarded the same way fused or not.
357 let mut eligible: Vec<bool> = vec![false; n];
358 for i in 0..n {
359 if lifecycles[i] != crate::kernel::EvalLifecycle::Dynamic {
360 continue;
361 }
362 let nd = dag.nodes[i].as_ref();
363 if !node_eligible(nd, &crate::compile::assembly::wire_types_of(dag, i)) {
364 continue;
365 }
366 if !crate::compile::none_rule_admits(
367 nd.accepts_none_inputs(),
368 &dag.wiring[i],
369 &eligible,
370 ) {
371 continue;
372 }
373 eligible[i] = true;
374 }
375
376 // Connected components over eligible-to-eligible wires, by the
377 // rule every fusing engine shares (compile::fusion_units). A
378 // volatile node never shares a cone with a node that is not: a
379 // cone runs whole, so every read that re-evaluates the volatile
380 // node would re-run the cached work upstream of it too.
381 let preds: Vec<Vec<usize>> = dag
382 .wiring
383 .iter()
384 .map(|w| {
385 w.iter()
386 .filter_map(|src| match src {
387 WireSource::NodeOutput(j, _) => Some(*j),
388 WireSource::Input(_) => None,
389 })
390 .collect()
391 })
392 .collect();
393 // Nor does a node that reads an extern a host can clear share
394 // one with a node that does not depend on it: a `None` on a
395 // cone's boundary makes every output of the cone `None`, and it
396 // must reach only the outputs that depend on the extern.
397 let class: Vec<u64> = volatile.iter().map(|&v| v as u64).collect();
398 let unset_read = crate::compile::externs::unset_read_inputs(
399 &dag.input_defs,
400 dag.coord_count,
401 &dag.const_inits,
402 );
403 let reads: Vec<Vec<usize>> = dag
404 .wiring
405 .iter()
406 .map(|w| {
407 w.iter()
408 .filter_map(|src| match src {
409 WireSource::Input(c) if unset_read.get(*c) == Some(&true) => Some(*c),
410 _ => None,
411 })
412 .collect()
413 })
414 .collect();
415 let class = crate::compile::fusion_units::refine_by_externs(&preds, &reads, &class);
416 let components = crate::compile::fusion_units::components(&preds, &eligible, &class);
417
418 // Consumer adjacency over the ORIGINAL node graph — the
419 // convexity walk below routes through it.
420 let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
421 for (i, ps) in preds.iter().enumerate() {
422 for &j in ps {
423 consumers[j].push(i);
424 }
425 }
426
427 // Every node's output types, read before the members leave the
428 // graph: a cone's boundary input may be an output of a node an
429 // earlier cone took.
430 let out_types: Vec<Vec<PortType>> = dag
431 .nodes
432 .iter()
433 .map(|nd| nd.meta().outs.iter().map(|p| p.typ).collect())
434 .collect();
435 let mut nodes_opt: Vec<Option<Box<dyn PolydatNode>>> = std::mem::take(&mut dag.nodes)
436 .into_iter()
437 .map(Some)
438 .collect();
439 let mut cones: Vec<(ConePlan, JitConeNode)> = Vec::new();
440
441 // A connected component is not necessarily convex: an
442 // eligible→ineligible→eligible sandwich whose ends connect
443 // through another eligible path lands both ends in one component
444 // while the middle stays out, and fusing it would make the
445 // middle both a consumer and a producer of the cone, a cycle in
446 // the spliced graph. Such a component is split into the convex
447 // pieces every fusing engine forms from it (engines.md §8), and
448 // each piece is then cut to the input bound. The nodes are in
449 // topological order, the order `convex_pieces` walks.
450 let topo: Vec<usize> = (0..n).collect();
451 let convex: Vec<Vec<usize>> = components
452 .into_iter()
453 .filter(|members| members.len() >= min_members)
454 .flat_map(|members| {
455 if crate::compile::fusion_units::is_convex(&members, &consumers) {
456 vec![members]
457 } else {
458 crate::compile::fusion_units::convex_pieces(&members, &preds, &topo, false)
459 }
460 })
461 .collect();
462
463 for members in &convex {
464 if members.len() < min_members {
465 continue;
466 }
467 let split = split_by_inputs(dag, members, &preds);
468 for (node, inputs) in split.over_bound {
469 record_fallback(
470 dag,
471 &[node],
472 &nodes_opt,
473 inputs,
474 ConeFallbackKind::InputBound,
475 format!(
476 "{inputs} distinct boundary inputs exceed the {MAX_CONE_INPUTS}-input \
477 piece bound"
478 ),
479 );
480 }
481 for piece in &split.pieces {
482 if piece.len() < min_members {
483 continue;
484 }
485 let Some(plan) = plan_cone(dag, piece, &nodes_opt, &out_types) else {
486 // plan_cone audit-logs its own rejection reason;
487 // the piece stays on the interpreter.
488 continue;
489 };
490 match build_cone(dag, &plan, &mut nodes_opt) {
491 Ok(cone) => {
492 // Formation is diagnosable state too: cone-aware
493 // bench reporting keys on this line to verify
494 // extraction actually ran.
495 crate::library::support::audit::debug(&format!(
496 "jit cone: fused {} members ({} boundary in, {} out): {}",
497 plan.members.len(),
498 plan.boundary_in.len(),
499 plan.boundary_out.len(),
500 cone.meta().name,
501 ));
502 cones.push((plan, cone));
503 }
504 // build_cone restored the members. Force builds
505 // native code or fails; Auto keeps the members on
506 // the interpreter and records the fallback.
507 Err(e) => {
508 if mode == JitMode::Force {
509 return Err(AssemblyError::NativeCone {
510 cone: label_of(plan.members.iter().map(|&m| {
511 nodes_opt[m]
512 .as_ref()
513 .map_or("", |nd| nd.meta().name.as_str())
514 })),
515 reason: e,
516 });
517 }
518 record_fallback(
519 dag,
520 &plan.members,
521 &nodes_opt,
522 plan.boundary_in.len(),
523 ConeFallbackKind::Codegen,
524 e,
525 );
526 }
527 }
528 }
529 }
530
531 if cones.is_empty() {
532 dag.nodes = nodes_opt.into_iter().map(Option::unwrap).collect();
533 return Ok(());
534 }
535 rebuild(dag, nodes_opt, cones);
536 Ok(())
537 }
538
539 /// Record on the tree's ledger that the cone of `members` stays on
540 /// the interpreter, and write the same to the audit channel.
541 fn record_fallback(
542 dag: &ResolvedDag,
543 members: &[usize],
544 nodes: &[Option<Box<dyn PolydatNode>>],
545 boundary_inputs: usize,
546 kind: ConeFallbackKind,
547 reason: String,
548 ) {
549 let names: Vec<String> = members
550 .iter()
551 .map(|&m| {
552 nodes[m]
553 .as_ref()
554 .map_or_else(String::new, |nd| nd.meta().name.clone())
555 })
556 .collect();
557 let mut outputs: Vec<String> = dag
558 .output_map
559 .iter()
560 .filter(|(_, (j, _))| members.contains(j))
561 .map(|(name, _)| name.clone())
562 .collect();
563 outputs.sort_unstable();
564 crate::library::support::audit::warn(&format!(
565 "jit cone: {} stays on the interpreter ({kind:?}): {reason}",
566 label_of(names.iter().map(String::as_str)),
567 ));
568 dag.ledger.record_cone_fallback(ConeFallback {
569 context: dag.context.clone(),
570 members: names,
571 outputs,
572 boundary_inputs,
573 kind,
574 reason,
575 });
576 }
577
578 /// A component cut into pieces within [`MAX_CONE_INPUTS`], and the
579 /// nodes no piece can hold, each with the boundary inputs it reads.
580 struct InputSplit {
581 pieces: Vec<Vec<usize>>,
582 over_bound: Vec<(usize, usize)>,
583 }
584
585 /// Cut the convex component `members` (ascending, so topological)
586 /// into pieces of at most [`MAX_CONE_INPUTS`] distinct boundary
587 /// inputs each. A component within the bound is one piece.
588 ///
589 /// Members join the open piece in topological order until the next
590 /// one would take its boundary over the bound; that member opens the
591 /// next piece. Each piece is a run of the component's topological
592 /// order, and a run of a convex component is convex: a path between
593 /// two of its members passes only through nodes between them in that
594 /// order, and a member between them is in the run, while a path
595 /// through a node outside the component would leave the component
596 /// and come back. Every wire between pieces runs forward, so the
597 /// pieces form no cycle. Each run is then cut into its connected
598 /// parts, which stay convex and never read more than the run did, so
599 /// a pull runs only the part its output needs. Every piece keeps the
600 /// component's class, so lifecycle, volatility, extern set, and
601 /// purity hold as they held for the component. A member that alone
602 /// reads more than the bound is in no piece.
603 fn split_by_inputs(dag: &ResolvedDag, members: &[usize], preds: &[Vec<usize>]) -> InputSplit {
604 let mut runs: Vec<Vec<usize>> = Vec::new();
605 let mut over_bound = Vec::new();
606 let mut run: Vec<usize> = Vec::new();
607 let mut read: std::collections::HashSet<(u8, usize, usize)> = Default::default();
608 // The distinct sources `m` reads from outside `run`, not yet in
609 // `read`.
610 let fresh = |m: usize, run: &[usize], read: &std::collections::HashSet<_>| {
611 let mut keys: Vec<(u8, usize, usize)> = dag.wiring[m]
612 .iter()
613 .filter(|src| {
614 !matches!(src, WireSource::NodeOutput(j, _) if run.binary_search(j).is_ok())
615 })
616 .map(src_key)
617 .filter(|k| !read.contains(k))
618 .collect();
619 keys.sort_unstable();
620 keys.dedup();
621 keys
622 };
623 for &m in members {
624 let keys = fresh(m, &run, &read);
625 if read.len() + keys.len() <= MAX_CONE_INPUTS {
626 read.extend(keys);
627 run.push(m);
628 continue;
629 }
630 if !run.is_empty() {
631 runs.push(std::mem::take(&mut run));
632 read.clear();
633 }
634 let keys = fresh(m, &run, &read);
635 if keys.len() > MAX_CONE_INPUTS {
636 over_bound.push((m, keys.len()));
637 continue;
638 }
639 read.extend(keys);
640 run.push(m);
641 }
642 if !run.is_empty() {
643 runs.push(run);
644 }
645 if runs.len() == 1 && over_bound.is_empty() {
646 return InputSplit {
647 pieces: runs,
648 over_bound,
649 };
650 }
651 let mut in_run = vec![false; preds.len()];
652 let classes = vec![0u64; preds.len()];
653 let mut pieces = Vec::new();
654 for run in runs {
655 for &m in &run {
656 in_run[m] = true;
657 }
658 pieces.extend(crate::compile::fusion_units::components(
659 preds, &in_run, &classes,
660 ));
661 for &m in &run {
662 in_run[m] = false;
663 }
664 }
665 InputSplit { pieces, over_bound }
666 }
667
668 /// Compute the cone's boundaries; `None` rejects the piece (dead
669 /// outputs, a mistyped or None-tolerant boundary, an unmarshalable
670 /// edge type).
671 /// `out_types` are every node's output types, the graph's before any
672 /// cone took its members.
673 fn plan_cone(
674 dag: &ResolvedDag,
675 members: &[usize],
676 nodes: &[Option<Box<dyn PolydatNode>>],
677 out_types: &[Vec<PortType>],
678 ) -> Option<ConePlan> {
679 let is_member = |j: usize| members.binary_search(&j).is_ok();
680
681 let mut boundary_in: Vec<WireSource> = Vec::new();
682 let mut in_types: Vec<PortType> = Vec::new();
683 let mut seen_in: HashMap<(u8, usize, usize), usize> = HashMap::new();
684 for &m in members {
685 let member = nodes[m].as_ref()?;
686 let member_ports: Vec<PortType> =
687 member.meta().wire_inputs().iter().map(|p| p.typ).collect();
688 let wire_types: Vec<PortType> = dag.wiring[m]
689 .iter()
690 .map(|src| match src {
691 WireSource::Input(i) => dag.input_defs[*i].port_type,
692 WireSource::NodeOutput(j, p) => out_types[*j][*p],
693 })
694 .collect();
695 // A node that lowers as a slot call runs the kit built for
696 // its wire types (compiled_handles.md §6), so its advertised
697 // port types do not bind its wires: a variadic that inspects
698 // `Value`s at P1 reads each wire as the wire is. A named
699 // native lowering takes its ports as declared.
700 let typed_by_wires = matches!(
701 classify_node_typed(member.as_ref(), &wire_types),
702 JitOp::SlotCall { .. }
703 );
704 for (k, src) in dag.wiring[m].iter().enumerate() {
705 let ty = wire_types[k];
706 // Inside a cone every wire is exactly its port's type.
707 if !typed_by_wires
708 && let Some(expected) = member_ports.get(k)
709 && *expected != ty
710 {
711 audit_skip(
712 members.len(),
713 &format!(
714 "input [{k}] of `{}` is a {ty:?} wire on a {expected:?} port",
715 member.meta().name
716 ),
717 );
718 return None;
719 }
720 let intra = matches!(src, WireSource::NodeOutput(j, _) if is_member(*j));
721 // engines.md §3.3: a None-tolerant member must not sit on the
722 // boundary, where a None could reach it (see the
723 // eligibility pass); a component split can put it there.
724 if !intra && member.accepts_none_inputs() {
725 audit_skip(
726 members.len(),
727 &format!(
728 "`{}` tolerates None inputs and input [{k}] is a boundary wire",
729 member.meta().name
730 ),
731 );
732 return None;
733 }
734 if intra {
735 continue;
736 }
737 let key = src_key(src);
738 if seen_in.contains_key(&key) {
739 continue;
740 }
741 if !scalar_ok(ty) {
742 audit_skip(
743 members.len(),
744 &format!("boundary input of type {ty:?} is not marshalable"),
745 );
746 return None;
747 }
748 seen_in.insert(key, boundary_in.len());
749 boundary_in.push(src.clone());
750 in_types.push(ty);
751 }
752 }
753 // `split_by_inputs` cut the component into pieces within the
754 // bound.
755 debug_assert!(boundary_in.len() <= MAX_CONE_INPUTS);
756 // A cone with no boundary inputs is a compile-time
757 // constant: it would evaluate exactly once (node_clean)
758 // and belongs to const folding, not per-cycle fusion.
759 // It also breaks lifecycle analysis (a no-input node
760 // claiming per-cycle outputs). Leave it interpreted.
761 if boundary_in.is_empty() {
762 // Normal outcome for const subgraphs — the fold passes
763 // own them; not worth an audit line.
764 return None;
765 }
766
767 let mut boundary_out: Vec<(usize, usize)> = Vec::new();
768 let mut seen_out: HashMap<(usize, usize), usize> = HashMap::new();
769 let mut note_out = |j: usize, p: usize| {
770 if let std::collections::hash_map::Entry::Vacant(e) = seen_out.entry((j, p)) {
771 e.insert(boundary_out.len());
772 boundary_out.push((j, p));
773 }
774 };
775 for (i, wiring) in dag.wiring.iter().enumerate() {
776 if is_member(i) {
777 continue;
778 }
779 for src in wiring {
780 if let WireSource::NodeOutput(j, p) = src
781 && is_member(*j)
782 {
783 note_out(*j, *p);
784 }
785 }
786 }
787 for (j, p) in dag.output_map.values() {
788 if is_member(*j) {
789 note_out(*j, *p);
790 }
791 }
792 if boundary_out.is_empty() {
793 // Dead subgraph (no observable outputs) — DCE
794 // territory, not worth an audit line.
795 return None;
796 }
797 let out_types: Vec<PortType> = boundary_out
798 .iter()
799 .map(|(j, p)| out_types[*j][*p])
800 .collect();
801 if out_types.iter().any(|t| !scalar_ok(*t)) {
802 audit_skip(members.len(), "a boundary output type is not marshalable");
803 return None;
804 }
805
806 Some(ConePlan {
807 members: members.to_vec(),
808 boundary_in,
809 in_types,
810 boundary_out,
811 out_types,
812 })
813 }
814
815 /// A boundary input's declared default, of its own type; the cone
816 /// is always evaluated with its inputs bound, so the default is
817 /// never read, but the definition is typed like any input's.
818 fn default_for(ty: PortType) -> Value {
819 match ty {
820 PortType::F64 => Value::F64(0.0),
821 PortType::Bool => Value::Bool(false),
822 PortType::Str => Value::Str("".into()),
823 PortType::Bytes => Value::Bytes(Vec::new().into()),
824 PortType::Json => Value::Json(std::sync::Arc::new(serde_json::Value::Null)),
825 PortType::U64 => Value::U64(0),
826 _ => Value::None,
827 }
828 }
829
830 /// Attempt native compilation of the planned cone. Codegen runs
831 /// before the members leave the graph permanently: on any error
832 /// they are restored and the caller keeps the interpreter form.
833 fn build_cone(
834 dag: &ResolvedDag,
835 plan: &ConePlan,
836 nodes: &mut [Option<Box<dyn PolydatNode>>],
837 ) -> Result<JitConeNode, String> {
838 let local: HashMap<usize, usize> = plan
839 .members
840 .iter()
841 .enumerate()
842 .map(|(l, &g)| (g, l))
843 .collect();
844 let in_pos: HashMap<(u8, usize, usize), usize> = plan
845 .boundary_in
846 .iter()
847 .enumerate()
848 .map(|(i, s)| (src_key(s), i))
849 .collect();
850
851 let sub_wiring: Vec<Vec<WireSource>> = plan
852 .members
853 .iter()
854 .map(|&m| {
855 dag.wiring[m]
856 .iter()
857 .map(|src| match src {
858 WireSource::NodeOutput(j, p) if local.contains_key(j) => {
859 WireSource::NodeOutput(local[j], *p)
860 }
861 other => WireSource::Input(in_pos[&src_key(other)]),
862 })
863 .collect()
864 })
865 .collect();
866 let sub_input_defs: Vec<InputDef> = plan
867 .in_types
868 .iter()
869 .enumerate()
870 .map(|(i, ty)| InputDef {
871 name: format!("c{i}"),
872 default: default_for(*ty),
873 port_type: *ty,
874 kind: InputKind::Coordinate,
875 type_origin: crate::kernel::TypeOrigin::Declared,
876 converts_to: None,
877 })
878 .collect();
879 let mut sub_output_map: HashMap<String, (usize, usize)> = HashMap::new();
880 let mut sub_output_order: Vec<String> = Vec::new();
881 for (k, (j, p)) in plan.boundary_out.iter().enumerate() {
882 let name = format!("o{k}");
883 sub_output_map.insert(name.clone(), (local[j], *p));
884 sub_output_order.push(name);
885 }
886
887 let taken: Vec<Box<dyn PolydatNode>> = plan
888 .members
889 .iter()
890 .map(|&m| nodes[m].take().expect("cone member present"))
891 .collect();
892 let member_label = label_of(taken.iter().map(|n| n.meta().name.as_str()));
893
894 let mut sub = ResolvedDag {
895 nodes: taken,
896 wiring: sub_wiring,
897 input_defs: sub_input_defs,
898 coord_count: plan.boundary_in.len(),
899 output_map: sub_output_map,
900 output_order: sub_output_order,
901 cursor_schemas: Vec::new(),
902 source: String::new(),
903 // A member's failure is reported against the program the
904 // cone stands in, as the same node's failure is reported on
905 // every other engine (engines.md §3.4); the cone is not a
906 // frame of its own.
907 context: dag.context.clone(),
908 output_modifiers: HashMap::new(),
909 const_outputs: std::collections::HashSet::new(),
910 const_inits: Vec::new(),
911 // A cone is a fragment of the program that stands in the
912 // tree's ledger already, not a program of its own: its
913 // kernel is recorded nowhere.
914 ledger: crate::kernel::CompileLedger::new(),
915 resources: dag.resources.clone(),
916 };
917
918 let restore = |sub_nodes: Vec<Box<dyn PolydatNode>>,
919 nodes: &mut [Option<Box<dyn PolydatNode>>]| {
920 for (&m, nd) in plan.members.iter().zip(sub_nodes) {
921 nodes[m] = Some(nd);
922 }
923 };
924
925 let layout = match PolydatAssembler::build_jit_layout(&sub) {
926 Ok(l) => l,
927 Err(e) => {
928 restore(sub.nodes, nodes);
929 return Err(e);
930 }
931 };
932 let (coord_slots, total_slots, jit_steps, jit_outputs, scratch, _volatile) = layout;
933 // Boundary inputs occupy the first slots, each as wide as its
934 // type.
935 let mut in_slots = Vec::with_capacity(plan.in_types.len());
936 let mut next = 0usize;
937 for ty in &plan.in_types {
938 in_slots.push(next);
939 next += ty.slot_width();
940 }
941 debug_assert_eq!(coord_slots, next);
942 let compiled = crate::compile::jit::compile_jit_entry(&jit_steps, Some(total_slots));
943 let (code_fn, code) = match compiled {
944 Ok(parts) => parts,
945 Err(e) => {
946 restore(sub.nodes, nodes);
947 return Err(e);
948 }
949 };
950
951 let out_slots: Vec<usize> = (0..plan.boundary_out.len())
952 .map(|k| jit_outputs[&format!("o{k}")])
953 .collect();
954 // Port metadata mirrors the fused subgraph rather than
955 // being synthesized: outputs clone the member's original
956 // port (lifecycle analysis and downstream diagnostics see
957 // what the interpreter form would have declared); inputs
958 // clone the source port where one exists (graph inputs are
959 // per-cycle by definition).
960 let meta = NodeMeta {
961 name: member_label,
962 ins: plan
963 .boundary_in
964 .iter()
965 .zip(&plan.in_types)
966 .enumerate()
967 .map(|(i, (src, ty))| {
968 // A boundary producer is an ineligible node, still
969 // in the slot vec, or a member of an earlier piece
970 // of the same component, already taken, whose port
971 // is built from the wire's type.
972 let mut port = match src {
973 WireSource::NodeOutput(j, p) => nodes[*j]
974 .as_ref()
975 .map(|nd| nd.meta().outs[*p].clone())
976 .unwrap_or_else(|| Port::new("", *ty)),
977 WireSource::Input(_) => Port::new("", *ty),
978 };
979 port.name = format!("c{i}");
980 port.constraint = None;
981 Slot::Wire(port)
982 })
983 .collect(),
984 outs: plan
985 .boundary_out
986 .iter()
987 .enumerate()
988 .map(|(k, (j, p))| {
989 let mut port = sub.nodes[local[j]].meta().outs[*p].clone();
990 port.name = format!("o{k}");
991 port.constraint = None;
992 port
993 })
994 .collect(),
995 };
996 let out_ports: Vec<(usize, usize)> = plan
997 .boundary_out
998 .iter()
999 .map(|(j, p)| (local[j], *p))
1000 .collect();
1001 // A member's failure names the member's outputs as the program
1002 // names them (engines.md §3.4), not as the cone numbers them: the boundary
1003 // outputs take the program's names for the attribution.
1004 let mut named = sub.output_map.clone();
1005 for (k, (j, p)) in plan.boundary_out.iter().enumerate() {
1006 let names: Vec<String> = dag
1007 .output_map
1008 .iter()
1009 .filter(|(_, v)| **v == (*j, *p))
1010 .map(|(n, _)| n.clone())
1011 .collect();
1012 if !names.is_empty()
1013 && let Some(target) = named.remove(&format!("o{k}"))
1014 {
1015 for n in names {
1016 named.insert(n, target);
1017 }
1018 }
1019 }
1020 let numbered = std::mem::replace(&mut sub.output_map, named);
1021 let attribution = std::sync::Arc::new(PolydatAssembler::attribution_of(&sub));
1022 sub.output_map = numbered;
1023 Ok(JitConeNode {
1024 attribution,
1025 in_slots,
1026 meta,
1027 code_fn,
1028 total_slots,
1029 out_slots,
1030 in_types: plan.in_types.clone(),
1031 out_types: plan.out_types.clone(),
1032 members: sub.nodes,
1033 sub_wiring: sub.wiring,
1034 out_ports,
1035 scratch,
1036 fallible: code.fallible(),
1037 _module: code,
1038 })
1039 }
1040
1041 /// Diagnostic name carrying the fused members, so an enriched
1042 /// eval panic attributes the interior functions.
1043 fn label_of<'a>(members: impl ExactSizeIterator<Item = &'a str>) -> String {
1044 const SHOWN: usize = 6;
1045 let count = members.len();
1046 let names: Vec<&str> = members.take(SHOWN).collect();
1047 let suffix = if count > SHOWN {
1048 format!("+{} more", count - SHOWN)
1049 } else {
1050 String::new()
1051 };
1052 format!("jit_cone[{}{}]", names.join("+"), suffix)
1053 }
1054
1055 /// Splice the compiled cones into the DAG and restore
1056 /// topological order.
1057 fn rebuild(
1058 dag: &mut ResolvedDag,
1059 nodes_opt: Vec<Option<Box<dyn PolydatNode>>>,
1060 cones: Vec<(ConePlan, JitConeNode)>,
1061 ) {
1062 let old_n = nodes_opt.len();
1063 // (old_idx, port) → (cone_ordinal, cone_out_port)
1064 let mut cone_port: HashMap<(usize, usize), (usize, usize)> = HashMap::new();
1065 for (ci, (plan, _)) in cones.iter().enumerate() {
1066 for (k, (j, p)) in plan.boundary_out.iter().enumerate() {
1067 cone_port.insert((*j, *p), (ci, k));
1068 }
1069 }
1070
1071 let mut kept_map: HashMap<usize, usize> = HashMap::new();
1072 let mut new_nodes: Vec<Box<dyn PolydatNode>> = Vec::new();
1073 let mut new_wiring: Vec<Vec<WireSource>> = Vec::new();
1074 for (old, slot) in nodes_opt.into_iter().enumerate() {
1075 if let Some(node) = slot {
1076 kept_map.insert(old, new_nodes.len());
1077 new_nodes.push(node);
1078 new_wiring.push(dag.wiring[old].clone());
1079 }
1080 }
1081 let cone_base = new_nodes.len();
1082 let mut cone_plans: Vec<ConePlan> = Vec::with_capacity(cones.len());
1083 for (plan, cone) in cones {
1084 new_nodes.push(Box::new(cone));
1085 new_wiring.push(plan.boundary_in.clone());
1086 cone_plans.push(plan);
1087 }
1088
1089 let remap = |src: &WireSource| -> WireSource {
1090 match src {
1091 WireSource::Input(i) => WireSource::Input(*i),
1092 WireSource::NodeOutput(j, p) => {
1093 if let Some(&nj) = kept_map.get(j) {
1094 WireSource::NodeOutput(nj, *p)
1095 } else {
1096 let (ci, k) = cone_port[&(*j, *p)];
1097 WireSource::NodeOutput(cone_base + ci, k)
1098 }
1099 }
1100 }
1101 };
1102 for wiring in new_wiring.iter_mut() {
1103 for src in wiring.iter_mut() {
1104 *src = remap(src);
1105 }
1106 }
1107 let mut new_output_map: HashMap<String, (usize, usize)> = HashMap::new();
1108 for (name, (j, p)) in dag.output_map.iter() {
1109 let (nj, np) = match remap(&WireSource::NodeOutput(*j, *p)) {
1110 WireSource::NodeOutput(a, b) => (a, b),
1111 WireSource::Input(_) => unreachable!("outputs map to nodes"),
1112 };
1113 new_output_map.insert(name.clone(), (nj, np));
1114 }
1115
1116 // Kahn topo sort — consumers of cone interiors may sit at
1117 // indices below the spliced cone node.
1118 let m = new_nodes.len();
1119 let mut indegree = vec![0usize; m];
1120 let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); m];
1121 for (i, wiring) in new_wiring.iter().enumerate() {
1122 let mut producers: Vec<usize> = wiring
1123 .iter()
1124 .filter_map(|s| match s {
1125 WireSource::NodeOutput(j, _) => Some(*j),
1126 WireSource::Input(_) => None,
1127 })
1128 .collect();
1129 producers.sort_unstable();
1130 producers.dedup();
1131 indegree[i] = producers.len();
1132 for j in producers {
1133 dependents[j].push(i);
1134 }
1135 }
1136 let mut order: Vec<usize> = Vec::with_capacity(m);
1137 let mut ready: std::collections::BinaryHeap<std::cmp::Reverse<usize>> = (0..m)
1138 .filter(|&i| indegree[i] == 0)
1139 .map(std::cmp::Reverse)
1140 .collect();
1141 while let Some(std::cmp::Reverse(i)) = ready.pop() {
1142 order.push(i);
1143 for &d in &dependents[i] {
1144 indegree[d] -= 1;
1145 if indegree[d] == 0 {
1146 ready.push(std::cmp::Reverse(d));
1147 }
1148 }
1149 }
1150 assert_eq!(
1151 order.len(),
1152 m,
1153 "cone splice must not introduce a cycle (old_n={old_n})"
1154 );
1155 let mut pos = vec![0usize; m];
1156 for (new_idx, &i) in order.iter().enumerate() {
1157 pos[i] = new_idx;
1158 }
1159
1160 let mut sorted_nodes: Vec<Option<Box<dyn PolydatNode>>> =
1161 new_nodes.into_iter().map(Some).collect();
1162 dag.nodes = order
1163 .iter()
1164 .map(|&i| sorted_nodes[i].take().expect("each node placed once"))
1165 .collect();
1166 dag.wiring = order
1167 .iter()
1168 .map(|&i| {
1169 new_wiring[i]
1170 .iter()
1171 .map(|s| match s {
1172 WireSource::Input(k) => WireSource::Input(*k),
1173 WireSource::NodeOutput(j, p) => WireSource::NodeOutput(pos[*j], *p),
1174 })
1175 .collect()
1176 })
1177 .collect();
1178 dag.output_map = new_output_map
1179 .into_iter()
1180 .map(|(name, (j, p))| (name, (pos[j], p)))
1181 .collect();
1182 let _ = cone_plans;
1183 }
1184}