Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tail recursion modulo cons #5569

Merged
merged 37 commits into from
Jun 25, 2023
Merged

tail recursion modulo cons #5569

merged 37 commits into from
Jun 25, 2023

Conversation

folkertdev
Copy link
Contributor

still incomplete for wasm, but I want to see the benchmark statistics

@folkertdev
Copy link
Contributor Author

@brian-carroll my attempt at writing down what is happening here. The current implementations of the wasm parts are just me trying to puzzle things together. I just don't have that good of a mental model of the details of wasm, so it is hard to implement truly new things.

so our code in question is this roc function

unfold : a, Nat -> LinkedList a
unfold = \value, n ->
    when n is
        0 -> Nil
        _ -> Cons value (unfold value (n - 1))

Which we compile to this IR now

procedure : `#UserApp.unfold` [<rnu><null>, C I64 *self]
procedure = `#UserApp.unfold` (`#Derived_gen.arg_0`: I64, `#Derived_gen.arg_1`: U32):
    let `#Derived_gen.null` : [<rnu><null>, C I64 *self] = NullPointer;
    let `#Derived_gen.initial` : Ptr([<rnu><null>, C I64 *self]) = lowlevel PtrToStackValue `#Derived_gen.null`;
    joinpoint `#Derived_gen.trmc` `#UserApp.value` `#UserApp.n` `#Derived_gen.hole`:
        let `#UserApp.29` : U32 = 0i64;
        let `#UserApp.30` : Int1 = lowlevel Eq `#UserApp.29` `#UserApp.n`;
        if `#UserApp.30` then
            let `#UserApp.24` : [<rnu><null>, C I64 *self] = TagId(1) ;
            let `#Derived_gen._ptr_write_unit` : {} = lowlevel PtrStore `#Derived_gen.hole` `#UserApp.24`;
            let `#Derived_gen.final` : [<rnu><null>, C I64 *self] = lowlevel PtrLoad `#Derived_gen.initial`;
            ret `#Derived_gen.final`;
        else
            let `#UserApp.28` : U32 = 1i64;
            let `#UserApp.27` : U32 = CallByName `Num.sub` `#UserApp.n` `#UserApp.28`;
            let `#Derived_gen.tag_arg_null` : [<rnu><null>, C I64 *self] = NullPointer;
            let `#UserApp.25` : [<rnu><null>, C I64 *self] = TagId(0) `#UserApp.value` `#Derived_gen.tag_arg_null`;
            let `#Derived_gen.newHole` : Ptr([<rnu><null>, C I64 *self]) = UnionFieldPtrAtIndex (Id 0) (Index 1) `#UserApp.25`;
            let `#Derived_gen._ptr_write_unit` : {} = lowlevel PtrStore `#Derived_gen.hole` `#UserApp.25`;
            jump `#Derived_gen.trmc` `#UserApp.value` `#UserApp.27` `#Derived_gen.newHole`;
    in
    jump `#Derived_gen.trmc` `#Derived_gen.arg_0` `#Derived_gen.arg_1` `#Derived_gen.initial`;

Some notes

  • initial is the starting hole. It is a (stack) pointer to a pointer-sized area of memory. the NULL value has been stored in this position. The idea of TRMC is to provide a hole for the next iteration to store its result in. We have to get this process started, and thus need to fabricate a hole the start. The first iteration of the loop will fill this initial hole with the head of the list. this can be either a NULL pointer to indicate Nil, or a non-null pointer to indicate a Cons cell.
  • we then have the TRMC joinpoint, with two branches
    • in the base case, we write the Nil constructor into the current hole, then read the value from initial, which now points to the head of the list.

    • in the recursive case, a cons cell with the updated current element is created. In the position where the recursive pointer normally goes we always insert a NULL pointer. We then use the new UnionFieldPtrAtIndex to get a pointer to this field. This is a heap pointer, but points to the middle of a cons node. This pointer will be the new hole.

      The old hole can then be plugged with the new (but kind of incomplete, because of that NULL field) cons node. Then the join points is invoked to process the rest of the input.

So, 3 new things lowlevels

  • the PtrStore : Ptr a, a -> {} lowlevel, store the value into the pointer
  • the PtrLoad : Ptr a -> a lowlevel, reads a value from a pointer
  • the PtrToStackValue : a -> Ptr a lowlevel, creates stack space for a value of type a, writes the given value into that space (NULL in our examples), and then returns a pointer to this space. (importantly, it does not take a reference to the input a value, it reserves claims new space and moves the a in there)

None of these do anything with RC (in fact, anything in a Ptr is not considered for RC). it is kind of unsafe, but it is what we need for TRMC.

Then also a new Expr was added, UnionFieldPtrAtIndex. For a recursive tag union, it gives you a pointer to a particular constructor's field.

ROC_PRINT_IR_AFTER_RESET_REUSE=1 cargo test-gen-wasm linked_list_trmc

@@ -27,7 +27,7 @@ extern fn free(c_ptr: [*]align(Align) u8) callconv(.C) void;
extern fn memcpy(dst: [*]u8, src: [*]u8, size: usize) callconv(.C) void;
extern fn memset(dst: [*]u8, value: i32, size: usize) callconv(.C) void;

const DEBUG: bool = false;
const DEBUG: bool = true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't forget to revert this

Comment on lines 224 to 225
// TODO perhaps we need the union_layout later as well? if so, create a new function/map to store it.
environment.add_union_child(*structure, *binding, *tag_id, *index);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the pointer should belong as union_child. We don't want it used in specialization.

Comment on lines 1046 to 1048
PtrStore => arena.alloc_slice_copy(&[owned, owned]),
PtrLoad => arena.alloc_slice_copy(&[owned]),
PtrToStackValue => arena.alloc_slice_copy(&[owned]),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As they are not rc'd. Why not mark them as irrelevant?

@@ -249,7 +249,7 @@ pub const DEBUG_SETTINGS: WasmDebugSettings = WasmDebugSettings {
let_stmt_ir: false && cfg!(debug_assertions),
instructions: false && cfg!(debug_assertions),
storage_map: false && cfg!(debug_assertions),
keep_test_binary: false && cfg!(debug_assertions), // see also ROC_WRITE_FINAL_WASM
keep_test_binary: true && cfg!(debug_assertions), // see also ROC_WRITE_FINAL_WASM
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't forget to revert

@brian-carroll
Copy link
Contributor

OK so my trusted debugging technique for Wasm code gen is to dump the disassembly to a .tmp file and annotate it line-by-line with:

  • what's on the stack machine
  • writes to local variables
  • changes to the stack memory frame

This usually uncovers anything weird happening. And there are a couple of weird things below, marked with !!!

004acb func[1023] <unfold>:   (i64, i32) -> i32
 004acc: 04 7f                      | local[0..3] type=i32
 004ace: 01 7e                      | local[4] type=i64
 004ad0: 09 7f                      | local[5..13] type=i32
 004ad2: 23 00                      | global.get 0       load global stack pointer
 004ad4: 41 10                      | i32.const 16       16 bytes stack space
 004ad6: 6b                         | i32.sub            subtract 16 from stack pointer
 004ad7: 22 03                      | local.tee 3        local3 is the frame pointer within this function
 004ad9: 24 00                      | global.set 0       update global stack pointer
 004adb: 02 40                      | block
 004add: 41 00                      |   i32.const 0                 ; [0]
 004adf: 21 04                      |   local.set 4                 ; []      local4=0i32
 004ae1: 20 03                      |   local.get 3                 ; [fp]
 004ae3: 20 04                      |   local.get 4                 ; [fp,0]
 004ae5: 36 02 00                   |   i32.store 2 0               ; []      frame=[0i32]
 004ae8: 20 03                      |   local.get 3                 ; [fp]
 004aea: 41 00                      |   i32.const 0                 ; [fp,0]
 004aec: 6a                         |   i32.add                     ; [fp]
 004aed: 21 05                      |   local.set 5                 ; []      local5=fp
 004aef: 02 40                      |   block                       ; []
 004af1: 20 00                      |     local.get 0               ; [value]
 004af3: 21 06                      |     local.set 6               ; []      local6=value
 004af5: 20 01                      |     local.get 1               ; [n]
 004af7: 21 07                      |     local.set 7               ; []      local7=n
 004af9: 20 05                      |     local.get 5               ; [fp]
 004afb: 21 08                      |     local.set 8               ; []      local8=fp
 004afd: 0c 00                      |     br 0                      ; []
 004aff: 0b                         |   end                         ; []
 004b00: 03 40                      |   loop                        ; []
 004b02: 41 00                      |     i32.const 0               ; [0]
 004b04: 20 07                      |     local.get 7               ; [0,n]
 004b06: 46                         |     i32.eq                    ; [n==0]
 004b07: 21 09                      |     local.set 9               ; []      local9=(n==0)
 004b09: 02 40                      |     block                     ; []
 004b0b: 20 09                      |       local.get 9             ; [n==0]
 004b0d: 0d 00                      |       br_if 0                 ; []
 004b0f: 41 01                      |       i32.const 1             ; [1]   ;;; --- start of n /= 0 branch
 004b11: 21 0a                      |       local.set 10            ; []      local10=1
 004b13: 20 07                      |       local.get 7             ; [n]
 004b15: 20 0a                      |       local.get 10            ; [n,1]
 004b17: 10 86 82 80 80 00          |       call 262 <sub_or_panic> ; [n-1]
 004b1d: 41 00                      |       i32.const 0             ; [n-1,0]
 004b1f: 21 0d                      |       local.set 13            ; [n-1,]      local13=0 (null)
 004b21: 41 18                      |       i32.const 24            ; [n-1,24]
 004b23: 41 08                      |       i32.const 8             ; [n-1,24,8]
 004b25: 10 fe 84 80 80 00          |       call 638 <roc_alloc>    ; [n-1,heap]
 004b2b: 22 0c                      |       local.tee 12            ; [n-1,heap]      local12=heap
 004b2d: 41 80 80 80 80 78          |       i32.const 2147483648    ; [n-1,heap,0x8000000]
 004b33: 36 02 04                   |       i32.store 2 4           ; [n-1,]
 004b36: 20 0c                      |       local.get 12            ; [n-1,heap]
 004b38: 41 08                      |       i32.const 8             ; [n-1,heap,8]
 004b3a: 6a                         |       i32.add                 ; [n-1,heap+8]
 004b3b: 21 0b                      |       local.set 11            ; [n-1,]          local11=heap+8
 004b3d: 20 0b                      |       local.get 11            ; [n-1,heap+8]
 004b3f: 20 06                      |       local.get 6             ; [n-1,heap+8,value]
 004b41: 37 03 00                   |       i64.store 3 0           ; [n-1,]
 004b44: 20 0b                      |       local.get 11            ; [n-1,heap+8]
 004b46: 20 0d                      |       local.get 13            ; [n-1,heap+8,0]  recursive_ptr=null
 004b48: 36 02 08                   |       i32.store 2 8           ; [n-1]
 004b4b: 41 08                      |       i32.const 8             ; [n-1,8]
 004b4d: 22 0e                      |       local.tee 14            ; [n-1,8]          local14=8
 004b4f: 21 03                      |       local.set 3             ; [n-1]            local3=8    !!! overwriting frame pointer !!!
 004b51: 20 03                      |       local.get 3             ; [n-1,8]
 004b53: 20 0b                      |       local.get 11            ; [n-1,8,heap+8]
 004b55: 36 02 00                   |       i32.store 2 0           ; [n-1]
 004b58: 20 06                      |       local.get 6             ; [n-1,value]
 004b5a: 21 06                      |       local.set 6             ; [n-1]
 004b5c: 21 07                      |       local.set 7             ; []              local7=n-1
 004b5e: 20 0e                      |       local.get 14            ; [8]
 004b60: 21 08                      |       local.set 8             ; []              local8=8
 004b62: 0c 01                      |       br 1                    ; []
 004b64: 0b                         |     end                       ; []
 004b65: 41 00                      |     i32.const 0               ; [0]  ;;; --- start of n == 0 branch
 004b67: 22 0f                      |     local.tee 15              ; [0]     local15=0
 004b69: 21 03                      |     local.set 3               ; []      !!! overwriting frame pointer !!!
 004b6b: 20 03                      |     local.get 3               ; [0]
 004b6d: 20 0f                      |     local.get 15              ; [0,0]
 004b6f: 36 02 00                   |     i32.store 2 0             ; []    store zero to the null pointer?! should be the frame pointer
 004b72: 20 05                      |     local.get 5               ; [fp]  previous copy of frame pointer (`hole`, I think?)
 004b74: 28 02 00                   |     i32.load 2 0              ; [*fp] hole?
 004b77: 21 02                      |     local.set 2               ; []    local2=*fp  the return value
 004b79: 0c 01                      |     br 1                      ; []    exit the loop
 004b7b: 0b                         |   end                         ; []
 004b7c: 0b                         | end                           ; []
 004b7d: 20 02                      | local.get 2                   ; [local2]  return value
 004b7f: 20 03                      | local.get 3                   ; [local2,fp]  load local frame pointer
 004b81: 41 10                      | i32.const 16                  ; [local2,fp,16]  16 bytes stack space
 004b83: 6a                         | i32.add                       ; [local2,fp+16]  add
 004b84: 24 00                      | global.set 0                  ; [local2]  set global stack pointer
 004b86: 0b                         | end                           ; [local2]

@brian-carroll
Copy link
Contributor

brian-carroll commented Jun 19, 2023

Oh it's also helpful to set DEBUG_SETTINGS.storage_map
It's a bit weird that _ptr_write_unit appears twice. Although it's probably the empty record, which doesn't generate any code anyway.
And I don't see a symbol for FrameOffset(0) which is used a lot in the actual code.


Storage:
`#Derived_gen.null` => Local { local_id: LocalId(4), value_type: I32, size: 4 }
`#Derived_gen.newHole` => Local { local_id: LocalId(14), value_type: I32, size: 4 }
`#Derived_gen.final` => VirtualMachineStack { vm_state: Pushed { pushed_at: 144 }, value_type: I32, size: 4 }
`#Derived_gen.tag_arg_null` => Local { local_id: LocalId(13), value_type: I32, size: 4 }
`#UserApp.24` => Local { local_id: LocalId(15), value_type: I32, size: 4 }
`#UserApp.30` => Local { local_id: LocalId(9), value_type: I32, size: 1 }
`#Derived_gen.arg_1` => Local { local_id: LocalId(1), value_type: I32, size: 4 }
`#Derived_gen.initial` => Local { local_id: LocalId(5), value_type: I32, size: 4 }
`#UserApp.value` => Local { local_id: LocalId(6), value_type: I64, size: 8 }
`#UserApp.29` => VirtualMachineStack { vm_state: Popped { pushed_at: 39 }, value_type: I32, size: 4 }
`#UserApp.25` => Local { local_id: LocalId(11), value_type: I32, size: 4 }
`#UserApp.28` => Local { local_id: LocalId(10), value_type: I32, size: 4 }
`#UserApp.27` => VirtualMachineStack { vm_state: Pushed { pushed_at: 60 }, value_type: I32, size: 4 }
`#Derived_gen.hole` => Local { local_id: LocalId(8), value_type: I32, size: 4 }
`#Derived_gen._ptr_write_unit` => StackMemory { location: FrameOffset(4), size: 0, alignment_bytes: 0, format: DataStructure }
`#Derived_gen.arg_0` => Local { local_id: LocalId(0), value_type: I64, size: 8 }
`#UserApp.n` => Local { local_id: LocalId(7), value_type: I32, size: 4 }
`#Derived_gen._ptr_write_unit` => StackMemory { location: FrameOffset(4), size: 0, alignment_bytes: 0, format: DataStructure }

@brian-carroll
Copy link
Contributor

brian-carroll commented Jun 19, 2023

My analysis so far is

  • This function seems to have only one non-zero-sized value in its stack frame, which is therefore at offset 0 in the frame
  • That makes it very easy to get mixed up between "the local that points to the base of the stack frame" and "the local that points to this specific value in the frame". (Things would be less confusing if we had a non-zero offset - say, in a procedure with more stuff in stack memory.)
  • We have two places where we are writing to the frame pointer. This is not how things are designed to work. Normally the frame pointer is written once at the start of the function and then used as a read-only value.
  • We probably want to have two different locals for these things, so that we are not overwriting the frame pointer.
  • I find it weird that "frame offset 0" has no symbol associated with it. I feel like there should be one symbol for the data in the stack frame and another symbol for the pointer that points to it. But maybe the IR doesn't work that way. I'm not sure if this is a problem or not. It just feels like an unusual scenario and a potential source of these bugs.

I still don't feel like I've figured out every detail of this and I cannot point to an exact fix yet. Just sharing my analysis so far.

@folkertdev
Copy link
Contributor Author

re.

I find it weird that "frame offset 0" has no symbol associated with it. I feel like there should be one symbol for the data in the stack frame and another symbol for the pointer that points to it. But maybe the IR doesn't work that way. I'm not sure if this is a problem or not. It just feels like an unusual scenario and a potential source of these bugs.

yes that is how the IR works, the extra symbol is not needed there. If it helps we could use one of the DEV_TMP symbols maybe?

the overwriting of the frame pointer is definitely not by design.

Copy link
Contributor Author

@folkertdev folkertdev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brian-carroll I got it to work, for that one example at least. I'm still not 100% sure about the implementation being correct in general though. I've left some inline questions.

Comment on lines +1888 to +1896
fn expr_union_field_ptr_at_index(
&mut self,
structure: Symbol,
tag_id: TagIdIntType,
union_layout: &UnionLayout<'a>,
index: u64,
symbol: Symbol,
storage: &StoredValue,
) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should try and share code with expr_union_at_index. may require some refactoring.

Comment on lines +1957 to +1964
let symbol_local = match self.storage.ensure_value_has_local(
&mut self.code_builder,
symbol,
storage.clone(),
) {
StoredValue::Local { local_id, .. } => local_id,
_ => internal_error!("A heap pointer will always be an i32"),
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what I'm not sure about

  • when is ensure_value_has_local needed
  • under what circumstances is it allowed to make assumptions about the storage. how can we be sure that Local is the only option here

_ => internal_error!("A heap pointer will always be an i32"),
};

self.code_builder.set_local(symbol_local);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe there is some helper for this (ensure symbol, then write to it)?

crates/compiler/gen_wasm/src/backend.rs Outdated Show resolved Hide resolved
Comment on lines +1968 to +1971
let (ptr_local_id, offset) = match backend.storage.get(&ptr) {
StoredValue::Local { local_id, .. } => (*local_id, 0),
_ => internal_error!("A pointer will always be an i32"),
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one is weird. This storage.get gets the correct local (the hole symbol, defined by the join point. But ensure_value_has_local instead gives LocalId(3) , which in practice corresponds with the frame pointer. I don't know why, but this works.

is the fact that the symbol comes from a join point relevant (because that is kind of brittle). again, should we consider options beside StoredValue::Local?

Copy link
Member

@ayazhafiz ayazhafiz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early comments

Comment on lines +103 to +106
when x1 is
Val a ->
when x2 is
Val b -> Val (a + b)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these changes make the program more amenable to TRMC, or is this for another reason?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I briefly thought so, but no. This is more in line with koka, so it is easier to compare our output to theirs.

Comment on lines -49 to -50
queen != q && queen != q + diagonal && queen != q - diagonal && safe queen (diagonal + 1) t

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nice if we could auto-desugar this to hit the tail-recursion analysis, rather than relying on an explicit if/else. Filed #5593

crates/compiler/collections/src/vec_set.rs Outdated Show resolved Hide resolved
Comment on lines +2812 to +2816
// we never consider pointers for refcounting. Ptr is not user-facing. The compiler
// author must make sure that invariants are upheld
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add this to the doc comment on LayoutRepr::Ptr?

Comment on lines 677 to 681
Boxed(InLayout<'a>),
Ptr(InLayout<'a>),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add some comments regarding the distinction between Boxed and Ptr? Namely that, if i understand correctly, Ptr is an alloca. I was thinking it would be too hard to do earlier, but maybe we could even reuse Ptr for pass-by-reference later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ptr is just some pointer (heap or stack) without any reference counting. The (lack of) RC is the defining feature.

Copy link
Member

@ayazhafiz ayazhafiz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incredible work! The logic looks great to me. Left various housekeeping comments.

crates/compiler/mono/src/tail_recursion.rs Show resolved Hide resolved
Comment on lines +56 to +62
let trmc_candidate_symbols = trmc_candidates(env.interner, proc);

if !trmc_candidate_symbols.is_empty() {
let new_proc =
crate::tail_recursion::TrmcEnv::init(env, proc, trmc_candidate_symbols);
*proc = new_proc;
} else {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for the future: i wonder if we could combine the TRMC + tail recursion search to avoid possibly two walks of a function

crates/compiler/mono/src/tail_recursion.rs Outdated Show resolved Hide resolved
crates/compiler/mono/src/tail_recursion.rs Outdated Show resolved Hide resolved
crates/compiler/mono/src/tail_recursion.rs Outdated Show resolved Hide resolved
Comment on lines +1624 to 1627
Ptr(inner_layout) | Boxed(inner_layout) => {
let inner_type =
layout_spec_help(env, builder, interner, interner.get_repr(inner_layout))?;
let cell_type = builder.add_heap_cell_type();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does morphic distinguish heap/stack alloca values, or are they equivalent?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we model the alloca as if it were on the heap here. morphic does not really have a reference operator, nor the concept of stack allocations. There are just stack values and everything else.

@@ -1165,7 +1165,12 @@ pub(crate) fn build_exp_expr<'a, 'ctx>(
env.builder.position_at_end(check_if_null);

env.builder.build_conditional_branch(
env.builder.build_is_null(tag_ptr, "is_tag_null"),
// have llvm optimizations clean this up
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice. maybe we want to do it ourselves in the future given the latency of roc-pg earlier, but i hope llvm just entirely skips unreachable blocks

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with optimization it does for sure.

crates/compiler/gen_llvm/src/llvm/build.rs Outdated Show resolved Hide resolved
crates/compiler/gen_llvm/src/llvm/build.rs Outdated Show resolved Hide resolved
crates/compiler/gen_dev/src/generic64/mod.rs Outdated Show resolved Hide resolved
Copy link
Member

@ayazhafiz ayazhafiz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 🚀

@ayazhafiz ayazhafiz merged commit 0edcd23 into main Jun 25, 2023
@ayazhafiz ayazhafiz deleted the finally-trmc branch June 25, 2023 20:31
@folkertdev folkertdev mentioned this pull request Jun 26, 2023
3 tasks
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.

4 participants