The Exec Pattern
Every executor in hylic has the same type-level structure. Two
traits — Executor (computation) and ExecutorSpec (lifecycle) —
and one wrapper — Exec<D, S> — compose into a uniform API where
every executor, regardless of whether it needs resources, presents
the same interface to the user.
The core idea
A Spec is a defunctionalized executor — pure data that fully
describes a computation strategy. It is Copy: small, moveable,
transformable. Calling .run() on a Spec refunctionalizes it: turns
the data back into computation.
For executors that need resources (thread pools, arenas), .run()
internally creates the resource, binds it, runs the fold, and
destroys the resource. For executors that need nothing (Fused), the
same .run() just runs.
#![allow(unused)]
fn main() {
use hylic::prelude::*;
// Sequential — no resource needed:
FUSED.run(&fold, &graph, &root);
// Parallel — resource created + destroyed internally:
exec(funnel::Spec::default(8)).run(&fold, &graph, &root);
}
The call shape is identical. Resource management is an internal concern of each executor.
The trait pair
#![allow(unused)]
fn main() {
/// Lifecycle: resource management + session creation.
/// Only Specs implement this. Sessions are the output.
pub trait ExecutorSpec: Copy {
/// Borrowed resource attached to a session (for example, a
/// thread-pool reference).
type Resource<'r> where Self: 'r;
/// The session type produced by `attach`.
type Session<'s>: 's where Self: 's;
/// Bind the spec to a borrowed resource, returning a session.
fn attach(self, resource: Self::Resource<'_>) -> Self::Session<'_>;
/// Construct an owned session scoped to `f` and run `f` against it.
fn with_session<R>(&self, f: impl for<'s> FnOnce(&Self::Session<'s>) -> R) -> R;
}
}
ExecutorSpec is the lifecycle trait. Two GATs define each executor’s
world:
Resource<'r>: what the executor needs. A thread pool (&'r Pool) for Funnel.()for Fused.Session<'s>: the bound executor — Spec + resource, ready to run folds. Borrows the resource at lifetime's.
Two methods connect them:
attach(self, resource): partial application. Consumes the Spec (it’s Copy — the caller keeps their copy), fixes the resource, produces a Session. This is the explicit path.with_session(&self, f): the scoped path. Creates the resource internally, attaches, callsfwith the session, cleans up. Copies the Spec (sinceattachconsumes andwith_sessionborrows&self).
#![allow(unused)]
fn main() {
/// Run a fold on a tree. Both Specs and Sessions implement this.
///
/// The fold is domain-specific (`D::Fold<H, R>`). The graph type G
/// is a trait-level parameter — each executor impl declares its own
/// bounds on G (e.g. Fused accepts any TreeOps, Funnel requires
/// Send+Sync). The compiler checks G at the call site.
pub trait Executor<N: 'static, R: 'static, D: Domain<N>, G: TreeOps<N> + 'static> {
/// Run the given `fold` over the `graph` starting at `root` and
/// return the fold's final result for the root.
fn run<H: 'static>(&self, fold: &D::Fold<H, R>, graph: &G, root: &N) -> R;
}
}
Executor is the computation trait. Both Specs and Sessions
implement it:
- Spec::run: routes through
self.with_session(|s| s.run(...))— creates the resource, runs, destroys - Session::run: direct dispatch — the resource is already bound
Exec<D, S>
#![allow(unused)]
fn main() {
/// User-facing executor wrapper tying a domain `D` to an executor
/// strategy `S`. Both Specs and Sessions appear inside `Exec`.
pub struct Exec<D, S>(pub(crate) S, PhantomData<D>);
#[allow(missing_docs)] // trivial constructor/accessor pair
impl<D, S> Exec<D, S> {
pub const fn new(inner: S) -> Self { Exec(inner, PhantomData) }
pub fn into_inner(self) -> S { self.0 }
}
impl<D, S: Clone> Clone for Exec<D, S> {
fn clone(&self) -> Self { Exec::new(self.0.clone()) }
}
impl<D, S: Copy> Copy for Exec<D, S> {}
}
The user-facing wrapper. D is the domain (determines fold/graph
types via GATs). S is the strategy — a Spec or a Session.
Exec is repr(transparent) over S and derives Copy when
S is Copy.
Two method blocks:
#![allow(unused)]
fn main() {
/// Run the inner strategy as an [`Executor`]. Inferred over `N`,
/// `H`, `R`, and `G` from the arguments.
pub fn run<N: 'static, H: 'static, R: 'static, G: TreeOps<N> + 'static>(
&self, fold: &<D as Domain<N>>::Fold<H, R>, graph: &G, root: &N,
) -> R
where D: Domain<N>, S: Executor<N, R, D, G>
{
Executor::<N, R, D, G>::run(&self.0, fold, graph, root)
}
}
Block A (.run()): available on ALL Exec where S: Executor.
This is the one way to execute. Works on Specs and Sessions alike.
#![allow(unused)]
fn main() {
impl<D, S: ExecutorSpec> Exec<D, S> {
/// Construct a session bound to an owned resource, pass it to
/// `f` by value (wrapping a borrowed session inside a fresh
/// `Exec<D, &Session>`), and return `f`'s result. The session
/// is dropped at the end of the scope.
pub fn session<R>(
&self,
f: impl for<'s> FnOnce(Exec<D, &S::Session<'s>>) -> R,
) -> R {
self.0.with_session(|session| f(Exec::new(session)))
}
/// Bind the spec to a borrowed resource, returning a session as
/// an `Exec`.
pub fn attach(self, resource: S::Resource<'_>) -> Exec<D, S::Session<'_>> {
Exec::new(self.0.attach(resource))
}
}
}
Block B (.session(), .attach()): available only on Spec-level
Exec where S: ExecutorSpec. These are the resource-management
surface:
.session(|s| ...): borrows the Spec, creates the resource in a scope, passes the session-levelExecto the closure. Multiple.run()calls inside share the resource..attach(resource): consumes the Spec (partial application), returns a session-levelExecbound to the resource. One expression — no intermediate bindings needed because Specs are Copy.
The three usage tiers
Executors can be used at three levels of resource control:
One-shot — the common case. Each .run() manages resources
internally:
#![allow(unused)]
fn main() {
exec(funnel::Spec::default(8)).run(&fold, &graph, &root);
}
Session scope — amortized multi-run. The resource (thread pool) is created once, shared across folds:
#![allow(unused)]
fn main() {
exec(funnel::Spec::default(8)).session(|s| {
s.run(&fold1, &graph1, &root1);
s.run(&fold2, &graph2, &root2);
});
}
Explicit attach — manual resource management. You provide the resource; the Spec binds to it:
#![allow(unused)]
fn main() {
funnel::Pool::with(8, |pool| {
exec(funnel::Spec::default(8)).attach(pool).run(&fold, &graph, &root);
});
}
For zero-resource executors (Fused), all three tiers compile but
.session() and .attach(()) are identity — the compiler optimizes
them away.
The impl table
Every executor fills the same shape:
| Type | Resource | Session | Executor::run |
|---|---|---|---|
fused::Spec | () | Self | direct recursion |
funnel::Spec<P> | &Pool | Session<P> | routes through with_session |
funnel::Session | — | — | direct dispatch::run_fold |
Sessions do NOT implement ExecutorSpec — they’re the output of
attach, not a Spec themselves.
Domain constants
Fused is a zero-sized Spec exposed as a domain-bound const:
pub const FUSED: Exec<Shared, fused::Spec> = Exec::new(fused::Spec);
FUSED is Copy. .run() calls Executor::run on fused::Spec
directly (it implements both traits). No resource, no session — the
Spec IS the session.
Generic-over-executor code
The Executor trait is the single generic bound. The graph type G
is a trait-level parameter — each executor impl declares its own
bounds on G:
fn measure<G: TreeOps<NodeId> + 'static, S: Executor<NodeId, u64, Shared, G>>(
exec: &Exec<Shared, S>, fold: &shared::Fold<NodeId, u64, u64>, graph: &G, root: &NodeId,
) -> u64 {
exec.run(fold, graph, root)
}
This works for Exec<Shared, fused::Spec>, Exec<Shared, funnel::Spec<P>>,
and Exec<Shared, funnel::Session<'_, P>> — all through the same
bound, the same .run(), the same call site.
How a new executor fits in
Adding a new executor requires implementing two traits:
- Define
MySpec(Copy) andMySession<'s> - Implement
ExecutorSpeconMySpec— defineResource,Session,attach,with_session - Implement
ExecutoronMySession— the direct dispatch - Implement
ExecutoronMySpec— route throughwith_session - Users:
shared::exec(MySpec { ... }).run(...)(or thelocal/ownedequivalent) — same shape as every other executor
The framework provides .run(), .session(), .attach() for free
via the Exec<D, S> wrapper.