Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, calls f with the session, cleans up. Copies the Spec (since attach consumes and with_session borrows &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-level Exec to the closure. Multiple .run() calls inside share the resource.
  • .attach(resource): consumes the Spec (partial application), returns a session-level Exec bound 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:

%3specSpec (data)Copy, transformableoneshot.run()one-shotcreates + destroys resourcespec->oneshotsimplestsession.session(|s| { … })scoped multi-runresource lives for closurespec->sessionamortizedattach.attach(resource)explicit bindingreturns session-level Execspec->attachmanual 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:

TypeResourceSessionExecutor::run
fused::Spec()Selfdirect recursion
funnel::Spec<P>&PoolSession<P>routes through with_session
funnel::Sessiondirect 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:

%3specMySpec (Copy)Resource<'r> = …Session<'s> = …attach_attach(self, resource)partial application→ MySessionspec->attach_explicitwithwith_sessioncreate resourceattach, scope, cleanupspec->withscopedrun_specimpl Executor for MySpecself.with_session(|s| s.run(…))spec->run_specone-shotsessionMySessionimpl Executordirect dispatchattach_->sessionwith->sessionrun_spec->with

  1. Define MySpec (Copy) and MySession<'s>
  2. Implement ExecutorSpec on MySpec — define Resource, Session, attach, with_session
  3. Implement Executor on MySession — the direct dispatch
  4. Implement Executor on MySpec — route through with_session
  5. Users: shared::exec(MySpec { ... }).run(...) (or the local/ owned equivalent) — same shape as every other executor

The framework provides .run(), .session(), .attach() for free via the Exec<D, S> wrapper.