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

Refactoring calls and returns #134

Closed
wants to merge 2 commits into from

Conversation

quaternic
Copy link

Changes in this PR:

  1. the next_block field in StackFrame is now an Option, where None means the Call didn't have a next_block
  2. that field is updated by the Call in that function, rather than the subsequent return from the callee
  3. UB from returning to a caller without a next_block is delayed to the next step, where it is seen that the top frame has next_block == None
  4. similarly, thread termination is delayed to when an enabled thread is selected for the next step, but has an empty stack, which also fixes the final return from a thread not deallocating its locals

These changes together mean the return from a function doesn't actually care about its caller's stack frame, which will help with further changes I have planned.

One downside is that this loses the information of which call-statement in the caller got us here, since it's possible for multiple call-statements to have the same next_block, but it could be tracked separately if needed for e.g. diagnostics.

@@ -48,17 +48,15 @@ struct StackFrame<M: Memory> {

/// `next_block` and `next_stmt` describe the next statement/terminator to execute (the "program counter").
/// `next_block` identifies the basic block,
next_block: BbName,
/// next_block is None after calling a function without giving a basic block to return to. UB is raised after the function returns.
next_block: Option<BbName>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think we want to allow this to be None. Why shouldn't we have UB immediately any time this would be set to None?

Copy link
Author

Choose a reason for hiding this comment

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

In the syntax for Terminator::Call, both the return place and the next block are optional (separately). This is set to None in any Call (or CallIntrinsic) without a next block, and that is sensible when calling a non-returning function. Returning anyway will then be UB, of course.

The conceptual idea with the field being Option would be that a stack frame may keep existing even when it will never execute another statement or terminator.

Copy link
Collaborator

Choose a reason for hiding this comment

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

a stack frame may keep existing even when it will never execute another statement or terminator.

I don't think that is something we want though.
Thinking about become, I would expect the old stack frame to be popped and then the new one to be pushed.

Copy link
Author

Choose a reason for hiding this comment

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

Oh yes, for become it would be. But a regular call to fn() -> !?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sure, what about that? We can already represent them. Just make a call that has neither a return block nor a return place.

Copy link
Author

Choose a reason for hiding this comment

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

A call to such a function is exactly the situation where (with this patch) next_block is set to None, as in "the caller will not be asked to execute another step, so it doesn't need to define what that step would be". Before this patch, the callee's caller_return_info.next_block would instead be set to None, so that if and when it tries to return, that will be UB.

With this patch, that return would technically be fine, but the next step in that thread is UB. This partially unifies the behaviour with returning from the bottom frame, which is not UB even if the function doesn't have a return local, although I would also like to change that towards requiring a return local to return at all.

Copy link
Collaborator

@RalfJung RalfJung Jul 27, 2023

Choose a reason for hiding this comment

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

A call to such a function is exactly the situation where (with this patch) next_block is set to None, as in "the caller will not be asked to execute another step, so it doesn't need to define what that step would be". Before this patch, the callee's caller_return_info.next_block would instead be set to None, so that if and when it tries to return, that will be UB.

Okay. I just don't think that's a good change though -- the current behavior seems better to me. I'd like updating the "current basic block" in the caller's frame to happen upon return, not during the Call. It helps with diagnostics, and it IMO better represents the intended semantics: the jump happens on Return.

With this patch, that return would technically be fine, but the next step in that thread is UB. This partially unifies the behaviour with returning from the bottom frame, which is not UB even if the function doesn't have a return local, although I would also like to change that towards requiring a return local to return at all.

Not requiring a return local is mostly a convenience so that the main function can be called without allocating space for a return local, and to make writing some tests simpler. I agree it's kind of ugly though. I would be on-board with making return locals mandatory (or tying them up with whether a return block is present), assuming this doesn't cause undue amounts of pain.

Copy link
Author

Choose a reason for hiding this comment

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

Okay. I just don't think that's a good change though -- the current behavior seems better to me. I'd like updating the "current basic block" in the caller's frame to happen upon return, not during the Call. It helps with diagnostics, and it IMO better represents the intended semantics: the jump happens on Return.

You have convinced me of that now. The rationale of making this change was more that the Return in one function shouldn't need to know anything about the caller's structure or stack frame. I previously considered Call to be something that is evaluated and then its done, and the entirety of the callee happens afterwards.

However, together with what I've learned from rust-lang/unsafe-code-guidelines#417, I'm now thinking that maybe Call should actually be a two-step thing: Pre-call, it prepares the return place (e.g. either allocate new place, or just pass the actual destination) and evaluates the arguments to form the callee's StackFrame. Post-call, if the return place was a temporary, copy to the actual destination and deallocate, and finally update next_block.

In other words, while a stack frame is not at the top of the stack, it has a Call "on hold" that will be finished after the function above it evaluates a Return. I'll have to see how it would end up looking in practice.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, that is a way to think about this. The CallerReturnInfo is basically the environment of a caller-defined closure that will be run after the callee returns to run the post-call actions. We don't actually want this to be a closure since having the data explicit will make it a lot easier to formally reason about this specification. (With such reasoning, equality might come in, and a struct like CallerReturnInfo has a much clearer notion of equality than a closure.)

Comment on lines +519 to +522
self.mutate_cur_frame(|frame| {
frame.next_block = next_block;
frame.next_stmt = Int::ZERO;
});
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure I conceptually agree with this -- I quite deliberately had the caller's stack frame point to the Call operation while the call is active. This is needed, for example, to get reasonable backtraces: we want to know where in the caller we were when the call was made, not where we will be when the call returns.

Copy link
Author

Choose a reason for hiding this comment

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

Indeed, for backtraces the call origin would need to be stored. A reasonable place might be the callee's CallerReturnInfo (probably renamed to just CallerInfo or something) . My understanding is still that this information is only needed for diagnostic reasons. It cannot affect the execution, right?

Copy link
Collaborator

Choose a reason for hiding this comment

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

It cannot affect the execution, right?

I think it cannot, yes.
But this diagnostics issue to me indicates that it's the wrong design choice to update the next_block before the call.

// Therefore the thread must terminate now.
assert_eq!(Int::ZERO, self.thread_manager.active_thread().stack.len());

return self.thread_manager.terminate_active_thread();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah, this is a good catch, currently we are failing to free the locals of the bottom frame.

We should probably have some tests that check for memory leaks...

@quaternic quaternic closed this Aug 5, 2023
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.

2 participants