Skip to content

[IR][JumpThreading] Fix infinite recursion on compare self-reference [updated] #129501

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

Merged
merged 22 commits into from
Apr 7, 2025

Conversation

ro-i
Copy link
Contributor

@ro-i ro-i commented Mar 3, 2025

EDIT March 21: updated title. See commits and current discussion for the current state of this PR.

In case of an unreachable loop, for example, the JumpThreading pass might attempt to remove the PHI node in the loop entry and replace it with its constant value. However, this fails in case the value is an instruction using the PHI node.

This LLVM defect was identified via the AMD Fuzzing project.

PS: I felt a bit discouraged from fixing this on the basic block level, but I think that the added check only verifies what should hold anyway.

In case of an unreachable loop, for example, the JumpThreading pass
might attempt to remove the PHI node in the loop entry and replace it
with its constant value. However, this fails in case the value is an
instruction using the PHI node.

This LLVM defect was identified via the AMD Fuzzing project.
@llvmbot
Copy link
Member

llvmbot commented Mar 3, 2025

@llvm/pr-subscribers-llvm-ir

@llvm/pr-subscribers-llvm-transforms

Author: Robert Imschweiler (ro-i)

Changes

In case of an unreachable loop, for example, the JumpThreading pass might attempt to remove the PHI node in the loop entry and replace it with its constant value. However, this fails in case the value is an instruction using the PHI node.

This LLVM defect was identified via the AMD Fuzzing project.

PS: I felt a bit discouraged from fixing this on the basic block level, but I think that the added check only verifies what should hold anyway.


Full diff: https://github.com/llvm/llvm-project/pull/129501.diff

2 Files Affected:

  • (modified) llvm/lib/IR/BasicBlock.cpp (+8-2)
  • (modified) llvm/test/Transforms/JumpThreading/unreachable-loops.ll (+60)
diff --git a/llvm/lib/IR/BasicBlock.cpp b/llvm/lib/IR/BasicBlock.cpp
index dca42a57fa9e3..9c644e0330258 100644
--- a/llvm/lib/IR/BasicBlock.cpp
+++ b/llvm/lib/IR/BasicBlock.cpp
@@ -556,8 +556,14 @@ void BasicBlock::removePredecessor(BasicBlock *Pred,
     if (NumPreds == 1)
       continue;
 
-    // Try to replace the PHI node with a constant value.
-    if (Value *PhiConstant = Phi.hasConstantValue()) {
+    // Try to replace the PHI node with a constant value, but make sure that
+    // this value isn't using the PHI node. (Except it's a PHI node itself. PHI
+    // nodes are allowed to reference themselves.)
+    if (Value *PhiConstant = Phi.hasConstantValue();
+        PhiConstant &&
+        (!isa<Instruction>(PhiConstant) || isa<PHINode>(PhiConstant) ||
+         llvm::all_of(Phi.users(),
+                      [PhiConstant](User *U) { return U != PhiConstant; }))) {
       Phi.replaceAllUsesWith(PhiConstant);
       Phi.eraseFromParent();
     }
diff --git a/llvm/test/Transforms/JumpThreading/unreachable-loops.ll b/llvm/test/Transforms/JumpThreading/unreachable-loops.ll
index d8bd3f389aae8..1a1a4ca15f649 100644
--- a/llvm/test/Transforms/JumpThreading/unreachable-loops.ll
+++ b/llvm/test/Transforms/JumpThreading/unreachable-loops.ll
@@ -180,4 +180,64 @@ cleanup2343.loopexit4:                            ; preds = %cleanup1491
   unreachable
 }
 
+; This segfaults due to recursion in %C4. Reason: %L6 is identified to be a
+; "partially redundant load" and is replaced by a PHI node. The PHI node is then
+; simplified to be constant and is removed. This leads to %L6 being replaced by
+; %C4, which makes %C4 invalid since it uses %L6.
+; The test case has been generated by the AMD Fuzzing project and simplified
+; manually and by llvm-reduce.
+
+define i32 @constant_phi_leads_to_self_reference() {
+; CHECK-LABEL: @constant_phi_leads_to_self_reference(
+; CHECK-NEXT:    [[A9:%.*]] = alloca i1, align 1
+; CHECK-NEXT:    br label [[F6:%.*]]
+; CHECK:       T3:
+; CHECK-NEXT:    [[L6:%.*]] = phi i1 [ [[C4:%.*]], [[BB6:%.*]] ]
+; CHECK-NEXT:    br label [[BB5:%.*]]
+; CHECK:       BB5:
+; CHECK-NEXT:    [[L10:%.*]] = load i1, ptr [[A9]], align 1
+; CHECK-NEXT:    br i1 [[L10]], label [[BB6]], label [[F6]]
+; CHECK:       BB6:
+; CHECK-NEXT:    [[LGV3:%.*]] = load i1, ptr null, align 1
+; CHECK-NEXT:    [[C4]] = icmp sle i1 [[L6]], true
+; CHECK-NEXT:    store i1 [[C4]], ptr null, align 1
+; CHECK-NEXT:    br i1 [[L6]], label [[F6]], label [[T3:%.*]]
+; CHECK:       F6:
+; CHECK-NEXT:    ret i32 0
+; CHECK:       F7:
+; CHECK-NEXT:    br label [[BB5]]
+;
+  %A9 = alloca i1, align 1
+  br i1 false, label %BB4, label %F6
+
+BB4:                                              ; preds = %0
+  br i1 false, label %F6, label %F1
+
+F1:                                               ; preds = %BB4
+  br i1 false, label %T4, label %T3
+
+T3:                                               ; preds = %T4, %BB6, %F1
+  %L6 = load i1, ptr null, align 1
+  br label %BB5
+
+BB5:                                              ; preds = %F7, %T3
+  %L10 = load i1, ptr %A9, align 1
+  br i1 %L10, label %BB6, label %F6
+
+BB6:                                              ; preds = %BB5
+  %LGV3 = load i1, ptr null, align 1
+  %C4 = icmp sle i1 %L6, true
+  store i1 %C4, ptr null, align 1
+  br i1 %L6, label %F6, label %T3
+
+T4:                                               ; preds = %F1
+  br label %T3
+
+F6:                                               ; preds = %BB6, %BB5, %BB4, %0
+  ret i32 0
+
+F7:                                               ; No predecessors!
+  br label %BB5
+}
+
 !0 = !{!"branch_weights", i32 2146410443, i32 1073205}

@dtcxzyw dtcxzyw requested review from arsenm and nikic March 3, 2025 10:10
Copy link
Contributor

@nikic nikic left a comment

Choose a reason for hiding this comment

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

It is legal to have self-referential instructions in unreachable code. If JumpThreading can't handle the result, that needs to be fixed, not the creation of the self-reference.

@ro-i
Copy link
Contributor Author

ro-i commented Mar 5, 2025

I added another implementation. This checks much more aggressively for blocks that become unreachable during pass execution. Consequently, I had to update a few of the existing tests because their checks contained unreachable blocks, which get deleted now by the pass. (If you agree that this is a viable solution, I may further need to adapt some of these tests because right now, they may not check for their actual test case anymore.

Copy link

github-actions bot commented Mar 5, 2025

✅ With the latest revision this PR passed the undef deprecator.

@ro-i
Copy link
Contributor Author

ro-i commented Mar 7, 2025

Ok, I now created a third approach :) Let me first show the situation more in detail:

define i32 @constant_phi_leads_to_self_reference(ptr %ptr) {
  %A9 = alloca i1, align 1
  br label %F6
 
BB4:                                              ; No predecessors!
  unreachable
 
F1:                                               ; No predecessors!
  unreachable
 
T3:                                               ; preds = %T4, %BB6
  %L6 = phi i1 [ %L6.pr, %T4 ], [ %C4, %BB6 ]
  br label %BB5
 
BB5:                                              ; preds = %F7, %T3
  %L10 = load i1, ptr %A9, align 1
  br i1 %L10, label %BB6, label %F6
 
BB6:                                              ; preds = %BB5
  %LGV3 = load i1, ptr %ptr, align 1
  %C4 = icmp sle i1 %L6, true
  store i1 %C4, ptr %ptr, align 1
  br i1 %L6, label %F6, label %T3
 
T4:                                               ; No predecessors!
  %L6.pr = load i1, ptr %ptr, align 1
  br label %T3
 
F6:                                               ; preds = %0, %BB6, %BB5
  ret i32 0
 
F7:                                               ; No predecessors!
  br label %BB5
}

This is the state of the function before the problem occurs while handling T4. Why is T4 a problem? It's the pre-header of T3 (which is a loop header). L6.pr is used by L6. The pass now detects that T4 is unreachable and removes it. Consequently, L6 become phi i1 [%C4, %BB6]. At a later step, this constant phi node will then be simplified. As a result, every use of L6 will be replaced by C4, which leads to a self-reference in C4.
IMHO, there are 3 good approaches to handle this problem:

  1. Avoid creating invalid IR at a general level. This was the first approach of my PR. This prevents replacing the phi node with its only value if that would lead to invalid IR.
  2. Avoid handling unreachable blocks. This was the second approach I pushed. However, this would require some analysis (especially since DT should not be used during execution of the pass, as @nikic mentioned here). Since @arsenm felt that would be too much. I tried to figure out another way without trying to avoid working on blocks that became unreachable during the execution of the pass.
  3. Avoid creating invalid IR at an early stage by avoiding deletion of loop pre-headers whenever they define instructions used by phi nodes in the loop header if removing these values from the phi nodes could lead to the phi node being removed and thus creating self-references (see the code for the details of the checks).

@ro-i
Copy link
Contributor Author

ro-i commented Mar 7, 2025

Edit: My terminology wasn't precise. I actually talked about a loop predecessor block, which in my test case is also a pre-header, but in general, we cannot determine whether it's only a loop predecessor or pre-header without further analysis (which I assume shouldn't be done here).

@ro-i ro-i requested review from arsenm and nikic March 10, 2025 09:18
@Meinersbur
Copy link
Member

Meinersbur commented Mar 17, 2025

I am assuming this is what causes the crash:

Check(U != (User *)&I || !DT.isReachableFromEntry(BB),
"Only PHI nodes may reference their own value!", &I);

Having looked closer into it, I think the fault is in the Verifier. Since dominance is not defined in unreachable code, checking dominance is explictly neutred:

if (!isReachableFromEntry(B))
return true;

An instruction referencing itself is a special case of violating dominance. Hence, it should be skipped in unreachable code.

Alternatively, InstSimplify needs to be changed in that before replacing an PHIInst (which is allowed to reference itself IF the incoming edge is dominated by the PHI -- the check that is skipped in unreachable code) with its only value does not cause any other instruction to reference itself (which is not allowed without exception for unreachable code).

In any case, this is not a problem with JumpThreading and there might also be cases in SimplifyCFG that trigger this. For instance, JumpThreading could just replace the instruction in the BB with an UnreachableInst instead of deleting it. That would just toss the same problem to SimplifyCFG.

@ro-i
Copy link
Contributor Author

ro-i commented Mar 17, 2025

Hm, I see that this issue might be even larger :D But I'm not sure whether I completely get what you're saying, @Meinersbur .

  • In the initial state of the IR function, no instruction is referencing itself. (And there are only statically unreachable blocks because the test case has already been simplified by llvm-reduce.) The IR should be valid, as far as I can tell.
  • The issue I see is that the JumpThreading pass does not properly recognize blocks that became unreachable by actions of the pass itself, so during the execution of the pass. The pass only checks for empty predecessors, which is not enough. Of course, it cannot use the DominatorTree, since that would be invalid during execution of the pass. But, as you pointed out in our other conversation, I could make use of EliminateUnreachableBlocks in BasicBlockUtils. This would mitigate what Matt said above because I would not reimplement the logic myself anymore :)
  • Another problem is that there are quite some tests for the JumpThreading pass that rely on the fact that the pass used to work on unreachable code instead of ignoring it. See, for example, https://github.com/llvm/llvm-project/blob/eef5ea0c42fc07ef2c948be59b57d0df8ec801ca/llvm/test/Transforms/JumpThreading/ne-undef.ll. The pass should determine that this boils down to a direct jump from bb to bb13, so bb1 would become unreachable directly after handling bb. And there are many such cases in the tests for this pass. I don't understand why, tbh.

@Meinersbur
Copy link
Member

Meinersbur commented Mar 18, 2025

I didn't see that its throug the actions of JumpThreading. Where exactly does crash occur. Within DeleteDeadBlock? Why does it try to fix dead BBs? At the verifier afterwards? After InstCombine on it?

Simplest fix would be to not to try to cleanup unreachable code in JumpThreading. Just let ADCE/SimplifyCFG handle it. Or call EliminateUnreachableBlocks at the end. That would even be cheaper than DeleteDeadBlocks since each invocation calls detachDeadBlocks which also iterates over all BBs.

There is comment saying:

We must remove BB to prevent invalid IR.

Which is AFAIK wrong. Unreachable code in itself is not invalid IR.

@ro-i
Copy link
Contributor Author

ro-i commented Mar 19, 2025

The error occurs while handling a self-referencing icmp instruction, %C4, as I described above. Initially, the instruction is not self-referencing. It becomes that way by actions of the pass. This leads to recursion and segfault. Of course, I could just prevent the recursion (that would be pretty easy), but that wouldn't fix the problem, right? Because it's only the symptom, not the disease...

Simplest fix would be to not to try to cleanup unreachable code in JumpThreading. [...] Or call EliminateUnreachableBlocks at the end.

That sounds reasonable. I implemented this yesterday and added an EliminateUnreachableBlocks at the end of the pass (and also added a flag to disable the elimination so that the tests relying on unreachable blocks don't become meaningless). However, when I recompiled llvm in release mode to run all the tests before pushing, I noticed some compiler errors. They happen during compilation of the offload runtimes. I guess that is what the "We must remove BB to prevent invalid IR." comment was about...
I researched the origin of that comment and found https://reviews.llvm.org/D44177 (from 2018). Even back then, they were aware that the handling of (un)reachability is essentially broken in this pass. They concluded:

The only way I know of preventing any wasted work done on unreachable regions is to check if BB is to maintain a set of blocks that are reachable from entry. We'd need to verify this state with calls to DT on almost every iteration inside ProcessBlock. It's not practical as it greatly lengthens compile time. For large functions like those found in sqlite3.c I was seeing a slowdown of 20% or more to track the 3,000+ blocks in the main REPL function when I initially tried this approach.

A few months before that (so in 2017), there was https://reviews.llvm.org/D34135. They also found that the JumpThreading pass has a fundamental problem, so they just prevented the bug they were dealing with instead of fixing the root cause :/

I mean, all this unreachable mess could be solved by materializing the lazy domtree info and just use the dominator tree to check for reachability, but that would be expensive. Also, I could call EliminateUnreachableBlocks on every inner iteration of the pass, but that would be expensive, too, I guess.
(Someone here mentioned something about a "dynamic" domtree, but ig that never made it into upstream?)

TLDR:

  • I fix the recursion by just preventing the recursion and not fixing the root cause of the recursion. (Easy)
  • I fix the handling of unreachable blocks by either materializing the domtree info or by eliminating unreachable blocks on every inner iteration of the pass. (Not so difficult to implement, but may potentially increase compile time)
  • Someone rewrites the pass. (I currently cannot do that.)

ro-i added 3 commits March 21, 2025 11:41
This reverts commit c97a6c6.
In unreachable code, constant PHI nodes may appear and be replaced by
their single value. As a result, instructions may become
self-referencing. This commit adds checks to avoid going into infinite
recursion when handling self-referencing compare instructions in
`evaluateOnPredecessorEdge()`.
@ro-i ro-i changed the title [IR] Avoid self-referencing values caused by PHI node removal [IR][JumpThreading] Fix infinite recursion on compare self-reference [updated] Mar 21, 2025
Copy link

github-actions bot commented Mar 21, 2025

✅ With the latest revision this PR passed the C/C++ code formatter.

evaluateOnPredecessorEdge(BB, PredPredBB, CondCmp->getOperand(0), DL);
Constant *Op1 =
evaluateOnPredecessorEdge(BB, PredPredBB, CondCmp->getOperand(1), DL);
Constant *Op0 = CondCmp->getOperand(0) == CondCmp
Copy link
Contributor

Choose a reason for hiding this comment

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

This handles one specific kind of recursion, but there may be recursion across multiple instructions. It would be better to add a Visited set instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You mean directly in evaluateOnPredecessorEdge?

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 could do something like this (see the commit I just pushed), I guess

Copy link
Contributor Author

@ro-i ro-i Mar 24, 2025

Choose a reason for hiding this comment

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

(+ I guess this could/should be a private method)

@ro-i
Copy link
Contributor Author

ro-i commented Mar 21, 2025

After talking about this again with @Meinersbur , I think I got a better view on this issue.
I initially didn't really understand your comment here, @nikic . Sorry about that. I was a bit confused because the pass itself has this comment about requiring the removal of unreachable blocks to prevent invalid IR. So I thought that we must either prevent invalid IR or remove unreachable blocks properly. But I now see that it's ok to just handle invalid IR and pass it along (it will then fail / be removed at a later stage).

@Meinersbur
Copy link
Member

Meinersbur commented Mar 24, 2025

When using a Visited list to stop infinite recursion, the list must be reverted to the previous state when leaving each level. Think of two icmp instructions referencing the same PHI (in reachable code!). The evaluateOnPredecessorEdge on the first icmp will mark the PHI as visited, the second would think the PHI is recursive since it was already visited.

Could it be sufficient to check CondComp->getOperand(0) == CondComp / CondComp->getOperand(1) == CondComp at some point? If iteratively replacing values with identical values, it should evantually replace everything in the chain with the same value. Also reminds me of finding a loop lin a singly-linked list.

EDIT: Didn't see the discussion already took place at #129501 (comment) The concern about reverting the Visited list (really the ancestor list) remains.

@ro-i
Copy link
Contributor Author

ro-i commented Mar 24, 2025

Hm, I'm not 100% sure about the issue. I'm currently using a set to track all visited values when visiting an instruction. As soon as another instruction is visited, there will be a new set. Am I missing something?

@ro-i
Copy link
Contributor Author

ro-i commented Mar 24, 2025

%x = icmp %a, %p
%y = icmp %x, %p

When handling %y, evaluateOnPredecessorEdge will descend into %x. There, it will already handle %p. Consequently, %p will already be in the Visited set when it should be handled as the second argument to %y.
That's what you meant, @Meinersbur , right?

Copy link
Contributor

@arsenm arsenm left a comment

Choose a reason for hiding this comment

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

This version seems plausible to me but I haven't looked in detail

@ro-i
Copy link
Contributor Author

ro-i commented Mar 25, 2025

Added a fix and a testcase for the issue @Meinersbur mentioned.

(@arsenm yeah, ig this approach does make sense. From what I've read in past discussions, others think that the pass needs a rewrite, but I'm glad it does not seem necessary at the moment and that I can fix the current issue in a more straightforward way :) )

@Meinersbur
Copy link
Member

Meinersbur commented Mar 25, 2025

Values might not just be reused by different icmp instructions, but also by different arguments and/or using different paths:

Preheader:
  br label %LoopHeader

LoopHeader:
  %common_phi = phi i32 [%Preheader, 0], [%Latch, %phi1]
  br label %Latch

Latch:
  %phi1 = phi i32 [%LoopHeader, %common_phi]
  %phi2 = phi i32 [%LoopHeader, %common_phi]
  %cmp = icmp eq %phi1, %phi2
  br %cmp, label %LoopHeader, label %Exit

Exit:

Assuming that evaluateOnPredecessorEdge recurses into all of the PHIs (I dont know the exact exit conditions), on the second visit, %common_phi has already been marked as visited. This is reachable code, so this should not happen. What should be the result of it:

Preheader:
  br label %LoopHeader

LoopHeader:
  %common_phi = phi i32 [%Preheader, 0], [%Latch, %common_phi]
  br label %Latch

Latch:
  %cmp = icmp eq %common_phi, %common_phi
  br %cmp, label %LoopHeader, label %Exit

Exit:

IIUC, the problem is when we do not have an external entry into the loop, in which case evaluateOnPredecessorEdge would recurse even into %common_phi:

LoopHeader:
  %common_phi = phi i32 [%Latch, %phi1]
  br label %Latch

Latch:
  %phi1 = phi i32 [%LoopHeader, %common_phi]
  %phi2 = phi i32 [%LoopHeader, %common_phi]
  %cmp = icmp eq %phi1, %phi2
  br %cmp, label %LoopHeader, label %Exit

Exit:

This is the condition we want to detect.

@ro-i
Copy link
Contributor Author

ro-i commented Mar 25, 2025

But evaluateOnPredecessorEdge recurses only into CmpInsts if I'm not mistaken?

When I run your example:

define void @foo() {
Preheader:
        br label %LoopHeader

LoopHeader:
        %common_phi = phi i32 [0, %Preheader], [%phi1, %Latch]
        br label %Latch

Latch:
        %phi1 = phi i32 [%common_phi, %LoopHeader]
        %phi2 = phi i32 [%common_phi, %LoopHeader]
        %cmp = icmp eq i32 %phi1, %phi2
        br i1 %cmp, label %LoopHeader, label %Exit

Exit:
        ret void
}

I get:

define void @foo() {
Preheader:
  br label %Latch

Latch:                                            ; preds = %Preheader, %Latch
  %common_phi = phi i32 [ 0, %Preheader ], [ %common_phi, %Latch ]
  %cmp = icmp eq i32 %common_phi, %common_phi
  br i1 %cmp, label %Latch, label %Exit

Exit:                                             ; preds = %Latch
  ret void
}

which should be what you intended?

Copy link
Member

@Meinersbur Meinersbur left a comment

Choose a reason for hiding this comment

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

I overlooked the responses after #129501 (comment). So yes, de6ae29 addresses my concern. The PHI was an example, I did not look into what evaluateOnPredecessorEdge recurses over.

I would prefer if the insert and erase would be done in the same call. A final review by @nikic would be good.

@ro-i
Copy link
Contributor Author

ro-i commented Mar 27, 2025

I would prefer if the insert and erase would be done in the same call.

implemented

Copy link
Contributor

@nikic nikic left a comment

Choose a reason for hiding this comment

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

Approach looks fine now

Constant *Op0 = nullptr;
Constant *Op1 = nullptr;
if (Value *V0 = CondCmp->getOperand(0); !Visited.contains(V0)) {
Visited.insert(V0);
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be cleaner to do these checks in evaluateOnPredecessorEdge(), not at each call to evaluateOnPredecessorEdge. Doesn't really matter right now, but will make sure this keeps getting handled if this code handles more than icmp in the future.

Please also combine the contains and insert calls by using the return value of insert.

Copy link
Contributor Author

@ro-i ro-i Apr 5, 2025

Choose a reason for hiding this comment

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

I think it would be cleaner to do these checks in evaluateOnPredecessorEdge(), not at each call to evaluateOnPredecessorEdge. Doesn't really matter right now, but will make sure this keeps getting handled if this code handles more than icmp in the future.

I adapted the code to insert and double check at the start of evaluateOnPredecessorEdge, see my latest commit. However, there still needs to be a contains before each call because, otherwise, the caller doesn't know whether it's allowed/required to erase the Value after the call.

Copy link
Contributor

Choose a reason for hiding this comment

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

You can also handle the erase in the same place, e.g. by adding a auto _ = make_scope_exit([V]() { Visited.erase(V); });

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 didn't know make_scope_exit, thanks!

Copy link
Contributor

@nikic nikic left a comment

Choose a reason for hiding this comment

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

LGTM

@ro-i ro-i merged commit 2f8b486 into llvm:main Apr 7, 2025
11 checks passed
@ro-i
Copy link
Contributor Author

ro-i commented Apr 7, 2025

Thanks to everyone for your reviews!

@ro-i ro-i deleted the phi-self-reference branch April 7, 2025 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants