Skip to content

Conversation

crusso
Copy link
Contributor

@crusso crusso commented Sep 12, 2025

Simpler stable functions

  • stable functions can only be declared within actors
  • stable functions receive new singleton types indicating the name of the functions as well as signature
  • stable functions subtype local function type
  • stable function types are stable types.
  • libraries can use bounds to abstract over stable and non-stable function types.

Advantages:

  • static check is enough (no special gc of stable functions, no runtime failure)
  • simple
  • no complicated naming scheme
  • able to distinguish types of increasing vs decreasing ordered sets by distinction of singleton types.
  • Implements the simpler stable function proposal for both classical and eop.

Disadvantages (over original stable functions)

  • much less expressive
  • doesn't support stable classes, just storing stable functions in stable value to avoid extra arguments to functions
  • type inference currently too limited to infer bounded types (like C <: Cmp<T>) below, requiring explicit instantiation, e.g.
    Set.empty<Int, Cmp>(cmp) vs Set.empty(cmp).
  • For HashMaps, that require two operations (hash and eq) we'd need to either pass two separate type parameters, or a single one bounded by a pair, but can drop the function arguments, storing them with the data.
  • If inference doesn't pan out, writing out the full singleton type is tedious.
    Abbreviations can help, but better would be some mechanism like ^cmp (type_of)
    Set.empty<Int, ^cmp>(cmp) vs Set.empty<Int, shared cmp (T,T) -> Order>(cmp).
    Simplest might be the implicit definition of type type cmp = shared cmp (T, T) -> Order from stable func cmp.
    Similar to what we do for classes, that define both a type and a value.

Dynamics:

  • We maintain a global record of stable functions, with mutable fields storing Local functions
  • A stable function declaration updates the record with the local version of the function, and returns a stable proxy.
  • The proxy is a closure with function index -1 and a single field storing the label/name of the stable function.
  • On closure application, we detect proxies (the -1) and indirect through the record using the label.
  • On deserialization, we allocate a proxy using just the expect function name. Could be shared but aren't for now.
  • For eop, proxies have a completely stable representation and can be re-used across upgrades without change.

TODO:

  • optimize Closure.prepare_call and Closure.call to avoid the redundant dynamic test in the second (easiest to modify call sites)
  • the write barriers when updating function table are ugly and should be refactored.
  • stable_func lookup would be much faster (possibly 0(1)) if the backend knew the type of the stable-record (optimizing and/or avoiding field search). Make it so or use binary search.
  • the original classic implementation in experiment: simple stable functions - dynamics #5379 is actually faster (no funky Closure.prepare) - maybe there's a compromise.
    Our indirect function calls are now more expensive.
  • Need to test upgrades from classical - eop.
  • frontend: Consider adding paired type definitions (as we do for classes) so its easier to write the type of shared function compare as type compare (= stable compare (Nat, Nat) -> Order.Order) (but beware of existing class punning, sigh)
  • replace current type inference hack with something more principled.
  • test recursive stable functions don't suck (and work)
  • optimize calls to known stable function to direct calls.
  • add tests for use-before-define
  • Add support for generic stable functions - needed fancier type descriptors c.f. Luc's stable functions.
  • ild.rs for skipping stable functions needs to do nothing (not advance)
  • type descriptor for stable functions (and maybe idl) (4) should also include name hash for safe comparison (and generics)
  • check graph stabilization works
  • decide what to do about generic bounds - at the moment, memory compatibility never promotes to a bound unlike Motoko subtyping. That means the static check may succeed while the dynamic check fails.
type Order = {#less; #equal; #greater};

module Set {

  public type Cmp<T> = (T,T) -> Order;

  public type List<T> = ?(T, List<T>);
  public type Set<T, C <: Cmp<T>> = (C, ?(T, List<T>));

  public func empty<T, C <: Cmp<T>>(c: C) : Set<T, C> = (c, null);

  public func add<T, C <: Cmp<T>>(s : Set<T, C>, v : T) : Set<T, C> {
    let (cmp, l) = s;
    func add(l  : List<T>) : List<T> {
      switch l {
        case null { ?(v, null) };
        case (?(w, r)) {
          switch (cmp(v, w)) {
            case (#less) { ?(v, r) };
            case (#equal) { l };
            case (#greater) { ?(w, add(r)) };
          };
        };
      };
    };
    (cmp, add(l));
  };

  public func mem<T, C <: Cmp<T>>(s : Set<T, C>, v : T) : Bool {
    let (cmp, l) = s;
    func mem(l : List<T>) : Bool {
      switch l {
        case null { false };
        case (?(w, r)) {
          switch (cmp(v, w)) {
            case (#less) { false };
            case (#equal) { true };
            case (#greater) { mem(r) };
          };
        };
      };
    };
    mem(l);
  };
};

actor Ok {

  // stable func dec
  stable func cmp(i : Int, j : Int) : Order {
     if (i < j) #less else if (i == j) #equal else #greater
  };

  stable let s1 = Set.empty(cmp);


};

actor Ok1 {

  func cmp(i : Int, j : Int) : Order {
     if (i < j) #less else if (i == j) #equal else #greater
  };

  transient let s1 = Set.empty(cmp);

};

actor Bad1 {

  /* stable */ func cmp(i : Int, j : Int) : Order {
     if (i < j) #less else if (i == j) #equal else #greater
  };

  stable let s1 = Set.empty(cmp); // reject, non-stable Cmp

};


actor Bad2 {

  /* stable */ func cmp(i : Int, j : Int) : Order {
     if (i < j) #less else if (i == j) #equal else #greater
  };
  type Cmp = stable cmp (Int, Int) -> Order;

  stable let s1 = Set.empty<Int, Cmp>(cmp); // reject, non-stable cmp

};


actor Bad3 {

  stable func inc(i : Int, j : Int) : Order {
     if (i < j) #less else if (i == j) #equal else #greater
  };
  type Inc = stable inc (Int, Int) -> Order;

  stable func dec(i : Int, j : Int) : Order {
     if (i < j) #less else if (i == j) #equal else #greater
  };
  type Dec = stable dec (Int, Int) -> Order;

  stable let s1 = Set.empty<Int, Inc>(inc);

  stable let s_ok = Set.add<Int, Inc>(s1, 1); // accept, same comparison

  stable let s_fail = Set.add<Int, Dec>(s1, 1); // reject, different comparison

};

crusso added 30 commits July 24, 2025 21:25
…:dfinity/motoko into claudio/simple-stable-functions-dynamics
…generic stable functions and graph copy (#5498)

* WIP

* one bug

* fix bug (inconsistent encodings)

* test for contravariant method update

* now I geddit

* remove todo

* add support for scope_binds

* fix

* add missing test results

* update test output

* refactor deserialization to check actual label

* fix eop compilation

* fix subtle type comparison bug (stable sorts need to compare the labels)

* fix broken test

* Updating `test/bench` numbers

* adjust comment

* fix broken, negated assert

---------

Co-authored-by: Cycle and memory benchmark updater <41898282+github-actions[bot]@users.noreply.github.com>
Copy link
Contributor

github-actions bot commented Sep 22, 2025

Comparing from 6b163e2 to 0a49bf6:
In terms of gas, 5 tests regressed and the mean change is +2.8%.
In terms of size, 5 tests regressed and the mean change is +7.1%.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant