Skip to content

Effect System

Status: in progress

This article covers effect system foundations. The formal row-polymorphism rules and effect lattice will be expanded from spec source 04_EFFECTS.csl.

An effect in Sigil is a capability a function uses that goes beyond returning a value. Calling println! is an effect (I/O). Allocating heap memory is an effect. Running on the GPU is an effect. Mutating global state is an effect.

In most languages, effects are implicit: a function can print to stdout whether or not its type says so. In Sigil, every effect is explicit in the function’s type. This is not optional — it is F2.

Effects appear after the parameter list, prefixed with !:

fn pure_fn(x: f32) -> f32 { x * x } // no annotation = pure
fn io_fn() !io { println!("hello"); } // I/O effect
fn gpu_fn() -> Vec4<Color> !gpu { /* ... */ } // GPU execution
fn alloc_fn() -> Vec<f32> !alloc { vec![1.0] } // heap allocation

Multiple effects can be combined:

fn io_alloc_fn() -> Vec<String> !io !alloc {
let mut v = Vec::new();
v.push(read_line()?);
v
}

You rarely need to write effect annotations explicitly. The compiler infers the effect row from the function body:

// Effect is inferred as !io because println! has !io
fn greet(name: &str) {
println!("Hello, {name}!");
}

You write explicit annotations when you want to enforce a constraint:

// This must remain pure. If a future edit adds !io, the compiler
// will reject the change — not silently allow it.
fn magnitude(v: Vec3) -> f32 !pure {
(v.x*v.x + v.y*v.y + v.z*v.z).sqrt()
}

Effects propagate up the call graph. If you call an !io function from inside a function, your function also has the !io effect:

fn log_result(x: f32) !io { // io because it calls println!
println!("{}", x * x);
}
fn compute_and_log(v: Vec3) !io { // io because it calls log_result
log_result(v.x);
}

The GPU/CPU boundary — the most important effect

Section titled “The GPU/CPU boundary — the most important effect”

The !gpu effect is special. It is incompatible with all CPU-side effects:

// This is a compile error:
#[shader(fragment)]
fn bad_shader(uv: Vec2) -> Vec4<Color> !gpu {
println!("uv: {uv}"); // ERROR: !io not allowed in !gpu context
Vec4::BLACK
}

GPU shaders cannot do I/O, cannot allocate CPU memory, cannot call CPU functions. The type system enforces this statically. This is how Sigil prevents an entire class of bugs that appear in GPU programming: accidentally calling a CPU function from shader code (which either fails silently at runtime or requires a driver roundtrip).

The CPU-to-GPU boundary is crossed through pipeline objects, not function calls. A Pipeline<VS, FS> connects a vertex shader to a fragment shader and submits work to the GPU. The CPU side has !io; the GPU side has !gpu; the pipeline manages the transfer.

// CPU side (!io): creates pipeline, submits draw call
let pipeline = Pipeline::new(vs_main, fs_main); // type-checks VS/FS interface
pipeline.draw(&vertices, &mut frame); // submits to GPU queue
// GPU side (!gpu): runs in parallel, no CPU access
#[shader(vertex)]
fn vs_main(v: Vertex) -> Vec4<Clip> !gpu { /* ... */ }

Effect rows are polymorphic — a function can be parameterized over the effects it is called with:

// This function works regardless of the effect context:
fn apply<T, E>(f: fn(T) -> T | E, x: T) -> T | E {
f(x)
}
// Works with pure functions, io functions, etc.
apply(|x: f32| x * x, 3.0); // pure call
apply(|x: f32| { println!("{x}"); x }, 3.0); // !io call

The | E syntax is the effect variable. This is the formal row-polymorphism that appears in the 04_EFFECTS.csl spec.

EffectMeaningTypical sources
(none)Pure — no observable effectsMath functions, pure transformations
!ioTouches I/Oprintln!, file ops, network, window
!gpuGPU-resident executionShaders, GPU compute
!allocHeap allocationVec::new(), Box::new(), String
!mutMutates shared/global statestatic mut, global registries
!panicMay panicArray indexing, unwrap(), arithmetic overflow
!asyncSuspends executionasync fn, .await