-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Recursion lint, part 2 of 2: Indirect Recursion #15020
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
Comments
@rustbot claim |
Design workPreambleThis lint reduces to finding loops in the function call graph. The Function Call Graph is a Directed Multigraph:
UX and error messagesIt might make the most sense to lint for the smallest call loops that we can find. The smallest loops should be easier to reason about, and this would allow us to do Early Exiting and avoid an explosive performance cost due to extremely long call loops. The Direct Recursion lint has already been implemented. That lint handles all loops of size 1. Therefore, the UX work of this lint will be focused on loops of size 2 or higher. The algorithmIn principle, I'd do an IDDFS (Iterative-Deepening Depth-First Search) over the function call graph. This is an algorithm for finding shortest paths in unweighted graphs, which uses very little memory (and thus hopefully should be faster than BFS, specially in very large programs). It also lets us keep the specific loop's details, which is something that many algorithms don't do. It also works seamlessly with multigraphs. The algorithm works as follows:
Performance considerationsFinal depthLet's say the program has N functions. Then, the Function Call Graph will have N nodes. Since loops of a larger length than N aren't going to be "elementary" (I can explain what this means in another comment), we can ignore them. If we have reached a Depth Limit of N and we still haven't found any call loops, we know the graph is loop-free, and therefore our program is recursion-free. What if "Too Many Edges"?Normal Graphs with N nodes have an edge limit of N^2. This is not the case for Multigraphs. Multigraphs can have an arbitrary number of edges between two nodes. In case this is a problem, I have an idea of an optimization that can be done to know in advance how long the smallest loops will be (or if there will be any at all). Let's say we ignore for a moment that this is a multigraph, and make an adjacency matrix for the Function Call Graph as if it were a normal graph. This would be a square matrix of NxN, where N is the number of functions in the program. The matrix is filled as follows.
Let this matrix be called
And furthermore:
The optimization I have in mind would be to first build This would allow us to run our IDDFS knowing the exact depth that we're looking for (making it k-DFS I guess), as well as knowing exactly what functions to start in (only the ones that have a non-zero It would also allow us to do an early exit in case the lint finds no loops in the graph. Algorithm requirements
The algorithm we use must be able to:
While hopefully:
Of all the algorithms I've been combing through this past week, only IDDFS (plus the optional Adjacency Matrix optimization) fit this bill. Please let me know if you can think of any alternatives that would too :) |
Why would you need a multigraph? There seems to me that there is several ways to use a regular directed graph.
Also, note that closures (and function refs) will need to be handled as well. For example, if function Also, closures and function refs might be stored in structures that will be passed to other functions (or used through a static variable with inner mutability) and called from there. This might be another difficulty there. |
@samueltardieu thank you! That's a lot of useful feedback.
The problem I see with that is that the size of the edge table is quadratic (in the number of functions) in the worst case. I'd rather have the table be implicit, and only put in memory the parts that must be used for emitting a lint. Are there other lints that incur in quadratic costs? Maybe, if it's not such a terrible cost in practice (specially with very large programs like Regardless, what you say about not needing a multigraph is quite valid. Even in IDDFS, I should be able to remember what fn calls I've already looked at and not recurse through calls to the same functions.
Indeed. That's my thinking process for using the adjacency matrix optimization. I can build the adjacency matrix first, and then find the size and starting point of the smallest loops by computing its powers. The AM is quadratic in the number of functions however, so I should probably be careful with how I represent it in memory. What's the single program with the most functions in the Rust ecosystem? I'd like to try testing how fast or slow the AM method is with programs of sizes such as those.
I'm going to separate this last bit in many pieces because I have a lot to ask about them.
This makes sense to me.
Why?
What do you mean by "clean", exactly? Because the main target for the Safety Critical guidelines about recursion is the (possibly uncontrolled) increase in stack size. If
You're right. I reckon those cases might be outside the scope of this lint. Maybe those should be addressed in a future extension, or through another SC Coding Guideline that restricts function refs + closures and statics with inner mutability. |
Imagine that Just as if, from
Maybe the "main" target, but probably not the only one. If you look at what is done in Ada for example, many standards forbid loops with dynamic bounds (a loop has to either loop forever when this is allowed, or a number of times determined before the loop starts in other cases). This is another situation where you might want to avoid recursion, even if tail recursion (and this controlled stack size) was guaranteed. |
Ah! I see what you mean now. That makes sense. However, I would leave this level of analysis to future improvements to the lint. I would be rehashing what I said in the PR for Direct Recursion: #15006 (comment), but basically this kind of analysis requires the compiler's dataflow analysis machinery to be a lot more sophisticated than it is right now. It requires an analysis tool that's at least capable of powering program-flow-aware constant propagation, which I know the current machinery to not be adequate for.
I get what you mean about loops (that's probably a coding guideline worthy of its own lint), but I don't get how recursion enters the picture here. Regarding Tail Recursion, there is a lint proposal since eons ago #1678, as well as a recent RFC that looks like it might finally give us guaranteed TR rust-lang/rfcs#3407. I don't think that lint is possible until we're able to guarantee TR, but whenever it becomes possible, I think it would be a nice complement to the Direct Recursion lint. |
Here's the optimization I have in mind, better written. The graph is the call graph of the program, which we build as an adjacency matrix.
The problem
From that graph, we want to know:
To do this, the fastest solution I've been able to think of is to use an adjacency matrix Sidenote: Dot Product for when Adjacency Matrices are implemented as bitfields
Basically it's a normal dot product, only that scalar results greater than 1 get clamped to 1. My solution draftFirst requirement: finding out if the graph has loopsAnyways. Using the adjacency matrix, we can quickly check if the graph contains loops. We know from algebraic graph theory:
And the largest power that matters for such an adjacency matrix is
We can reach the
If the result is This step then has a complexity of Second requirement: finding out the smallest loop lengthOptimizing the first requirementA shortcut we can take for when there are loops in the graph, is the following fact:
So in our prior requirement, if we check at every step for the presence of a non-zero diagonal element, we will know there are loops in the graph and can continue on. Actually computing the second requirementKnowing that some power Finding out this special Third requirementAfter calculating Since this step reuses the result of the second step, we incur a cost of Total cost and better alternatives?Sparse adjacency matricesThere is something to be said about using sparse matrices. However, sparse adjacency matrices quickly become dense when we start computing their powers. So sparse matrices are out of the question. Floyd-WarshallOne could argue that the Floyd-Warshall algorithm fares better, since it's only of cost Total costI don't think there's anything better than O(N^3) for computing this lint. I might be able to give a proof of this if it's needed. But my gut and a quick scribble of the associated costs tell me that it's not lower than That being said, my hope is that by leveraging fast matrix multiplications, we can make the constants associated with each operation as small as possible. And that for small enough programs, performing this computation is reasonably fast. |
Uh oh!
There was an error while loading. Please reload this page.
What it does
This is a
restriction
lint, mostly intended to be used in Safety Critical contexts.It checks for indirect recursive calls.
It's a follow-up to #428, the Direct part of which has been implemented in #15006
I plan to be implementing it during this month.
Advantage
There are no universal good recommendations over the original code. Some recursive algorithms can be converted to constant-memory loops, but a lot of them actually require a stack to work. The reason why recursion is tightly controlled in Safety Critical systems is them running out of memory. This is why we cannot just recommend a loop with a stack: it would still potentially run out.
There is no "right" answer to how to change the original code:
That's why this lint should issue no recommendations.
Drawbacks
I can think of two drawbacks of running this lint.
Example
The text was updated successfully, but these errors were encountered: