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

Proposal: A definition of naked functions based on comptime evaluation #21415

Open
alexrp opened this issue Sep 14, 2024 · 10 comments
Open

Proposal: A definition of naked functions based on comptime evaluation #21415

alexrp opened this issue Sep 14, 2024 · 10 comments
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Milestone

Comments

@alexrp
Copy link
Member

alexrp commented Sep 14, 2024

Background & Motivation

Some context:

As can be seen from these issues, we're having to come up with a growing list of language rules to make naked functions work reliably, and I worry that we're going to keep running into edge cases in both the language and compiler implementation that will need special handling for naked functions. These rules add extra complexity throughout Sema, and they're really just trying to contend with a rather simple reality: All compilers that implement GCC-style inline assembly and naked functions, including LLVM, consider asm statements to be the only well-defined contents of naked functions. This is a reasonable stance for a compiler to take because, in the absence of an ABI-compliant prologue and epilogue, there are very few constructs that can be lowered correctly. It also makes a lot more sense if you think of naked functions as just being a convenient language feature for emitting a black box of machine instructions in function form, which is how GCC/Clang define them.

Rather than this growing list of language rules and the implementation complexity that come with them, I'd like to suggest what I think will be a simpler way of specifying and implementing naked functions. This proposal will make Zig's naked functions match the GCC/Clang definition and therefore also conform to LLVM's requirements.

(Note that some elements of this proposal are not unique to it; for example, the asm restrictions that I describe below will have to be adopted in some form even if this particular proposal is rejected.)

Proposal

A function definition annotated with callconv(.Naked) is known as a naked function. A naked function must have an empty parameter list and must have noreturn as its return type. A naked function cannot be annotated with inline, noinline, or extern. A naked function cannot be called directly, instead requiring a function type cast first, at which point a more detailed signature can be supplied. The compiler treats a naked function as a black box for the purposes of optimization and code generation; reachable calls to a naked function cannot be inlined, reordered, or elided.

The compiler will perform basic scaffolding for a naked function, such as defining the symbol in the resulting object file, but it will not emit any machine instructions in the function body - not even the usual prologue and epilogue. The programmer is expected to provide the implementation by way of inline assembly. By virtue of the noreturn return type, it is considered safety-unchecked undefined behavior for control to reach the end of a naked function. (Note: This is unchecked because the panic handler cannot be invoked from a naked function. That said, compilers are encouraged to (and do) insert a single faulting instruction at the end of a naked function, making debugging a bit easier.)

A naked function has its body comptime-evaluated during semantic analysis; that is, its body is implicitly a comptime { ... } block. During this evaluation, asm expressions are recorded rather than executed, and have some additional restrictions (see below). Besides this, all the usual comptime rules apply. After comptime evaluation is done, the function's body is fully replaced with the machine instructions resulting from the concatenation and assembly of the recorded asm expressions, in lexical order. No further compiler transformations are performed on the function body. (Note: The semantics here are very similar to container-level comptime blocks and the way asm expressions are treated there.)

In a naked function, there are some additional restrictions for asm expressions:

  • The volatile annotation is not permitted. (Note: asm volatile is a meaningless concept in naked functions because of their black box nature.)
  • Inputs are allowed, but the operands must evaluate to comptime-known values.
    • The only permissible input constraints are those which do not require emitting extra machine instructions to pass the input operand into the assembly block. The list of these is inherently target-specific, but usually includes X and s.
    • A compiler backend may place further target-specific restrictions on the types of input operands.
      • Most targets will require an input operand to be at most pointer-sized.
      • Some targets cannot generally accept a floating-point input operand because it would require emitting extra machine instructions.
  • Outputs and clobbers are not allowed. (Note: This is because naked functions are already assumed to possibly do anything. This rule also implies that asm expressions in naked functions have no meaningful result value.)

Open Questions

  • If we're concerned about the implicit comptime-ness of the function body as a result of just the callconv(.Naked) syntax, to make the semantics explicit, we could require that naked functions always contain just a single comptime block.
  • It's worth considering noinline as a required annotation on naked functions to make explicit the fact that the compiler is not allowed to inline naked functions.
  • I go back and forth on asm volatile in naked functions. It's easy to argue that volatile should be required to make explicit the fact that asm expressions in a naked function can never be dropped by the compiler. On the other hand, forbidding volatile is consistent with container-level asm. I don't feel particularly strongly about either approach; I just think we should either require or forbid volatile, rather than status quo where we apply the asm volatile rules that are used in regular function context.
@alexrp
Copy link
Member Author

alexrp commented Sep 14, 2024

cc @andrewrk @jacobly0 @mlugg @Rexicon226 in particular for feedback on this.

Even if we decide not to go with this approach, I think we should at least adopt the asm restrictions for naked functions, as well as the requirements for an empty parameter list and noreturn return type. I can type up a separate proposal for that if this one is rejected.

@Rexicon226
Copy link
Contributor

Rexicon226 commented Sep 15, 2024

Great write-up, I really like this idea.

The compiler treats a naked function as a black box for the purposes of optimization and code generation; reachable calls to a naked function cannot be inlined, reordered, or elided.

  1. I'm curious how you intend to do this. Are these semantics already guaranteed by LLVM, i.e. does applying the naked attribute to a function make it a black box to the optimizer?
  2. Why? For example, LLVM can just see into ASM and understand what it's doing (assuming it's not volatile, which is still up for debate). Why disallow it from re-ordering it, unless of course, it's an already guaranteed semantic as asked by the question above?
    I guess this is a bit of a question/response to your asm volatile follow-up question, but I think that just not changing the current semantics, allowing the backend to reorder non-volatile asm sections, not touch volatile ones, is for the best. It provides the flexibility the user wants while allowing the backend to optimize as it pleases.

And I'll just re-iterate my response to this from the Zulip:

It's worth considering noinline as a required annotation on naked functions to make explicit the fact that the compiler is not allowed to inline naked functions.

I disagree with this idea. Currentlynoinline provides two meanings in Zig.

  1. It tells the function's caller that it cannot be called in an inlined fashion, so @call(.inline, ...).
  2. It tells the optimizing backend that the function should not be inlined. The backend will retain the call to the function.

When applying these ideas to naked functions, we see that the first point doesn't exist since we cannot semantically call naked functions. I feel like the second point is implied from the naked calling convention, which must be explicitly written out of course, and since it doesn't provide the user with any additional information, would be redundant.

@alexrp
Copy link
Member Author

alexrp commented Sep 15, 2024

I'm curious how you intend to do this. Are these semantics already guaranteed by LLVM, i.e. does applying the naked attribute to a function make it a black box to the optimizer?

It's possible we'll have to apply noduplicate and nomerge in addition to naked + noinline. I haven't personally seen any cases where that was needed, but it wouldn't hurt to do it for good measure.

Why? For example, LLVM can just see into ASM and understand what it's doing (assuming it's not volatile, which is still up for debate). Why disallow it from re-ordering it, unless of course, it's an already guaranteed semantic as asked by the question above?

I wrote the rule this way mostly for simplicity. It is of course true that a sufficiently smart compiler (tm) could analyze the assembly of the naked function and determine that some optimizations could be done while preserving semantics. I'm just not aware of any compilers that are even remotely sophisticated enough to do this, and it would be much harder to accurately describe what "preserving semantics" actually means in this context. I think that unless we can come up with any compilers that actually do this kind of optimization, we shouldn't complicate the definition of naked functions for this.

Also, a use case that would break without this rule would be a naked function with a bunch of placeholder machine code that gets patched at run time. We might just say we don't care about such an esoteric case. I don't feel strongly about it, but it's something to consider.

@Vexu Vexu added the proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. label Sep 15, 2024
@Vexu Vexu added this to the 0.15.0 milestone Sep 15, 2024
@Rexicon226
Copy link
Contributor

Gotcha, both responses make sense to me.

Also, a use case that would break without this rule would be a naked function with a bunch of placeholder machine code that gets patched at run time. We might just say we don't care about such an esoteric case. I don't feel strongly about it, but it's something to consider.

To be clear, this is the exact use case where you'd write asm volatile I think.

@alexrp
Copy link
Member Author

alexrp commented Sep 16, 2024

To be clear, this is the exact use case where you'd write asm volatile I think.

Ok, yeah, I see what you're saying.

I'm still concerned about the idea of asms in naked functions being non-volatile by default though. That's a significant departure (and potential footgun) compared to naked functions in C.

@mlugg
Copy link
Member

mlugg commented Sep 18, 2024

That's a significant departure compared to naked functions in C.

Are you sure about that? As far as I can tell, GCC doesn't treat inline asm any different in naked vs "normal" functions. The difference is just that in GCC/Clang, asm without outputs is basically just assumed to be volatile AFAICT. Zig instead approaches this by making non-volatile asm without output constraints a compile error.

To what extent and in what contexts optimization of inline asm should be allowed (and is even useful) within Zig is a question worth asking, but I don't think it's one that we need to answer here. AFAICT, no contemporary compiler attempts to optimize inline asm (I tried to find any references to LLVM doing this and came up dry; if you have any, please share!). The only noteworthy effect the volatile keyword has, then, is disallowing the asm block from being elided. However, that's not relevant here; elision only matters when the asm's outputs are all unused. If an asm block has no outputs (the common case for naked functions), Zig currently requires it to be volatile. If it does have outputs, within a naked function these must be globals (since we can't have local variables), so elision is impossible anyway.

In fact, not requiring the volatile keyword here would mean we would have to eliminate the AstGen error for assembly expression with no output must be marked volatile, since this would be valid in callconv(.Naked) functions.

To me it seems pretty clear-cut that inline asm in naked functions should obey the typical volatile rules, which in practice will currently more-or-less mean volatile is always required (and is a compile error to omit). Perhaps this will change in the future, but that's for future proposals to figure out.


Aside from that issue, I'm fairly happy with the state of this proposal. It allows you to do fancy comptime control flow to figure out which asm to include if you want, but ultimately gives us a bulletproof definition of naked functions, which is straightforward for backends to deal with, relatively easy to specify (it's just a third way of interpreting asm expressions), and pretty elegant. My main lingering concern is that the implicit comptime eval could be unintuitive here; for instance, imagine a user writing this:

fn foo() callconv(.Naked) noreturn {
    asm volatile ("ret");
    unreachable;
}

I can imagine someone doing this with the intention of satisfying the noreturn return type. So, then, it'd be pretty confusing to get a error: reached unreachable code at compile time! That said, maybe a note on that error (and also on @panic) in naked functions would solve that problem.

Also, it's a little weird that the noreturn return type isn't really meaningful; I agree it's the least weird thing to be there, but it's not like the function body has to "satisfy" that return type statically.


I kind of feel like that last point is a symptom of the problem that we're trying to use functions -- which are really a part of the type system -- to model something which is fundamentally untyped. It almost makes me inclined to propose removing CallingConvention.Naked altogether in favour of a builtin:

// @nakedFunction(comptime asm: []const u8) *const anyopaque
// return value is ptr to the function consisting of `asm`.
// essentially minor syntax sugar over global asm plus `@extern`.
const foo = @nakedFunction(
    \\ret
);
// in fact, hell, why not instead use RLS to figure out the return
// type, so you can type it correctly straight away?
const bar: *const fn () callconv(.C) void = @nakedFunction(
    \\ret
);

I don't want to dedicate any time to thinking about this right now, so I'll leave it up to others here to figure out if there's anything there. There might well not be, I just figured it was worth throwing that idea into the wild.

@alexrp
Copy link
Member Author

alexrp commented Sep 18, 2024

Are you sure about that? As far as I can tell, GCC doesn't treat inline asm any different in naked vs "normal" functions.

Huh, I guess I misremembered. 🤔 In that case, and given the other points, I guess I'm fine with leaving the asm volatile rules alone.

If it does have outputs, within a naked function these must be globals (since we can't have local variables), so elision is impossible anyway.

Note, though, that the proposal explicitly disallows asm outputs in naked functions; an asm expression having an output means that it has a result, and where would you put that result in a naked function? You basically run into a very similar problem to #21193.

An alternative design here would just be to allow output constraints, but special-case asm expressions to have no result in naked functions.

I can imagine someone doing this with the intention of satisfying the noreturn return type. So, then, it'd be pretty confusing to get a error: reached unreachable code at compile time! That said, maybe a note on that error (and also on @panic) in naked functions would solve that problem.

Yeah, I think the solution there would just have to be a smarter diagnostic message.

I kind of feel like that last point is a symptom of the problem that we're trying to use functions -- which are really a part of the type system -- to model something which is fundamentally untyped. It almost makes me inclined to propose removing CallingConvention.Naked altogether in favour of a builtin:

Some immediate thoughts:

I'll think a bit about this and see if I can come up with something satisfying based on your idea.

@mlugg
Copy link
Member

mlugg commented Sep 18, 2024

an asm expression having an output means that it has a result

Huh? No, you can have output constraints which go straight to globals etc without having a result from the asm expression. I think we should only disallow result outputs in asm expressions in naked functions.

  • It would need to be able to take constraints like asm, since you'd otherwise be unable to reference other symbols as inputs/outputs.

Yep -- I think the syntax within @nakedFunction could be the same as within the parenthese of an asm expression. Given that, it might make more sense to use a keyword, but I'm not sure how we'd feel about reserving a keyword for this.

I think that's pretty much a non-issue once we include the existing input/output constraint syntax; we just need to change @nakedFunction (or the naked_fn keyword) to align to whatever syntax asm gets down the line.

@alexrp
Copy link
Member Author

alexrp commented Sep 20, 2024

Huh? No, you can have output constraints which go straight to globals etc without having a result from the asm expression. I think we should only disallow result outputs in asm expressions in naked functions.

... oh. The only examples we have in the language reference use result outputs, and I never took notice of this comment:

// Next is either a value binding, or `->` and then a type. The
// type is the result type of the inline assembly expression.
// If it is a value binding, then `%[ret]` syntax would be used
// to refer to the register bound to the value.

so this whole time I assumed Zig's syntax only supports result outputs. But ok, yeah, I agree then.

@alexrp
Copy link
Member Author

alexrp commented Sep 23, 2024

It almost makes me inclined to propose removing CallingConvention.Naked altogether in favour of a builtin

Having thought some more on this, I think removing CallingConvention.Naked is right. Ultimately, a function has to adhere to a well-understood CC to be callable, so it really does feel wrong for naked to be in the CC position.

With that in mind, I think it's worth considering just using plain old function syntax but with an extra modifier:

pub asm fn add(a: i32, b: i32) callconv(.C) i32 {
    asm volatile (
        \\ leal (%%rdi, %%rsi), %%eax
        \\ ret
    );
}

asm fn would be how you create a naked function (we would use different terminology than "naked" in the language reference; maybe "assembly-only function" or something). By not making it a CC, you can specify e.g. callconv(.C), and at that point, having parameter types and a return type kind of makes sense again because you can actually specify the full function signature as it should be seen by callers. As a result, you can call it without any awkward casting too, and we would no longer need special rules in Sema to catch direct calls to naked functions (which we currently fail to do in all cases). Then we'd just say that an asm fn can't ever refer to its arguments, and I think we'd be good?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Projects
None yet
Development

No branches or pull requests

4 participants