effect handlers in rust
Go to file
annieversary db54fa50d8 benchmarks 2022-01-23 05:18:37 +00:00
benches benchmarks 2022-01-23 05:18:37 +00:00
effers-derive readme and examples n stuff 2022-01-21 13:16:03 +00:00
examples readme and examples n stuff 2022-01-21 13:16:03 +00:00
src readme and examples n stuff 2022-01-21 13:16:03 +00:00
.gitignore start and finish lmao 2022-01-19 20:56:39 +00:00
Cargo.toml benchmarks 2022-01-23 05:18:37 +00:00
README.org benchmarks 2022-01-23 05:18:37 +00:00

README.org

effers

ergonomic effect handlers in rust

how to use

defining effects

effects are defined with traits

trait Printer {
    fn print(&self, s: &str);
    fn available() -> bool;
}
trait Logger {
    fn debug(&mut self, s: &str);
    fn info(self, s: &str);
}

functions can take self, &self, &mut self, or no self parameter. at this point self parameters with a specified type (like self: Box<Self>) are not supported

defining a program

programs are defined as a normal function, with the added program attribute, which specifies (optional) a name for the program, and (required) the list of effects and corresponding functions that are used

#[effers::program(MyCoolProgram =>
    Printer(print(&self) as p, available as printer_available),
    Logger(debug(&mut self), info(self))
)]
fn my_program(val: u8) -> u8 {
    if printer_available() {
        p("hey hi hello");
    }

    debug("this is a debug-level log");
    info("this is a info-level log");

    val + 3
}
name

the first token (MyCoolProgram) will be the name of the program. this is optional, and can be skipped:

#[program(
    Printer(print(&self) as p, available as printer_available),
    Logger(debug(&mut self), info(self))
)]

if skipped, the default name will be the program function's name (my_program) in PascalCase (MyProgram)

listing effects

effects are listed by writing the trait's name, followed by a parenthesized list of the functions that will be used

listing effect functions

due to limitations of proc-macros, it's unknown what kind of self parameter the function takes, if any, and so it has to be explicitly specified (if you have ideas on how to fix this, please open a PR!): here's how each type is specified:

  • fn print();: print
  • fn print(self);: print(self)
  • fn print(mut self);: print(self)
  • fn print(&self);: print(&self)
  • fn print(&mut self);: print(&mut self)
effect function aliases

functions can be given an alias using the as keyword (print(&self) as p) so that the function can be called by a different name inside the program

defining effect handlers

effect handlers are defined by declaring a struct, and implementing the corresponding trait on it

struct IoPrinter;
impl Printer for IoPrinter {
    fn print(&self, s: &str) {
        println!("{}", s)
    }
    fn available() -> bool {
        true
    }
}

struct FileLogger;
impl Logger for FileLogger {
    fn debug(&mut self, s: &str) {
        println!("debug: {}", s)
    }
    fn info(self, s: &str) {
        println!("info: {}", s)
    }
}

running programs

programs are run by providing the corresponding handlers in the order listed in the program definition, and finally calling the run method, providing it the required parameters

let result: u8 = MyCoolProgram.add(IoPrinter).add(FileLogger).run(3);
assert_eq!(result, 6);

performance

running programs in effers is really fast. i'll first explain the reasoning why, and then i'll show benchmarks in case you don't believe me :)

explanation

the macro replaces every call to an effect function to be a call to the corresponding trait, and since it uses generics, the type is known at compile time and therefore there is no dynamic dispatch. for example, the program in the module example ends up being the following:

impl<A: inc::Incrementer> ProgWithIncrementer<A> {
    fn run(mut self, val: u8) -> u8 {
        let x = <A as inc::Incrementer>::increment(&self.1, val);
        let y = <A as inc::Incrementer>::increment(&self.1, x);
        x + y
    }
}

note: this is literally the output of cargo expand, you can try it yourself!

when running the program with Prog.add(inc::TestInc).run(1), rust fully knows at compile time that the increment effect function is from the trait Implementer, and it's being called on TestInc. since all of this is known at compile time, rust can perform all normal optimizations, and the cost of using effers is practically none

benchmarks

note: i do not know how to properly benchmark libraries, so if you think what i did is not correct, please feel free to open an issue/PR. i followed the example showcased in Alexis King's Effects for Less talk, which should properly test the actual effect system's cost on programs. i recommend you look at that talk if you haven't already, as it's highly informative, and it explains why this benchmark makes sense. the tldw is that when benchmarking effect systems, we want to know the performance cost of using the effect system, we don't care about benchmarking the effects themselves, and so we need simple effects so that the cost of the system is appreciable in comparison

the test is run with input of 20 and 20000

the benchmark compares an implementation using effers:

#[program(State(get(&self), put(&mut self)))]
fn prog() -> u32 {
    loop {
        let n = get();
        if n <= 0 {
            return n;
        } else {
            put(n - 1);
        }
    }
}

with a plain-rust implementation:

fn prog(mut n: u32) {
    let r = loop {
        if n <= 0 {
            break n;
        } else {
            n = n - 1;
        }
    };
    assert_eq!(0, r);
}

the following are the results:

state: effers: 20       time:   [319.18 ps 319.78 ps 320.34 ps]
                        change: [-0.9133% -0.6671% -0.4224%] (p = 0.00 < 0.05)
                        Change within noise threshold.
Found 2 outliers among 100 measurements (2.00%)
  2 (2.00%) high mild

state: effers: 20000    time:   [320.23 ps 320.64 ps 321.02 ps]
                        change: [-0.0515% +0.2343% +0.5306%] (p = 0.11 > 0.05)
                        No change in performance detected.
Found 18 outliers among 100 measurements (18.00%)
  13 (13.00%) low mild
  3 (3.00%) high mild
  2 (2.00%) high severe

state: no effect system: 20
                        time:   [319.94 ps 321.22 ps 323.39 ps]
                        change: [-0.5255% -0.1001% +0.3816%] (p = 0.69 > 0.05)
                        No change in performance detected.
Found 12 outliers among 100 measurements (12.00%)
  8 (8.00%) low mild
  1 (1.00%) high mild
  3 (3.00%) high severe

state: no effect system: 20000
                        time:   [319.41 ps 319.85 ps 320.27 ps]
                        change: [-2.4698% -1.9813% -1.5456%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 2 outliers among 100 measurements (2.00%)
  2 (2.00%) high mild

now, i might be wrong about this, but it seems that there is no extra cost incurred by using effers :)

im pretty sure that that is wrong, and that the compiler is doing some extra optimizations i am not aware of. again, if you know how to improve this benchmark, please let me know

building a program

there might be some performance cost in building a program before running it, since it uses the builder pattern and a bunch of functions have to be called, but the benchmarks above show it's not an appreciable difference