11 ms·
The Rust calling convention we deserve
- AceJohnny2 3y agoIn contrast: "How Swift Achieved Dynamic Linking Where Rust Couldn't " (2019) [1] On the one hand I'm disappointed that Rust still doesn't have a calling convention for Rust-level semantics. On the other hand the above article demonstrates the tremendous amount of work that's required to get there. Apple was deeply motivated to build this as a requirement to make Swift a viable system language that applications could rely on, but Rust does not have that kind of backing. [1] https://faultlore.com/blah/swift-abi/ https://faultlore.com/blah/swift-abi/ HN discussion: https://news.ycombinator.com/item?id=21488415 https://news.ycombinator.com/item?id=21488415
- fl0ki 3y agoIt's only fair to point out that Swift's approach has runtime costs. It would be good to have more supported options for this tradeoff in Rust, including but not limited to https://github.com/rust-lang/rfcs/pull/3470 https://github.com/rust-lang/rfcs/pull/3470
- ninkendo 3y agoNotably these runtime costs only occur if you’re calling into another library. For calls within a given swift library, you don’t incur the runtime costs: size checks are elided (since size is known), calls can be inlined, generics are monomorphized… the costs only happen when you’re calling into code that the compiler can’t see.
- retox 3y agoMeta: the minimap is quite interesting, it's 'just' a smaller copy of all the content.
- deleted 3y ago[deleted]
- edflsafoiewq 3y agoClever! Should probably have aria-hidden though.
- yogorenapan 3y agoTangentially related: Is it currently possible to have interop between Go and Rust? I remember seeing someone achieving it with Zig in the middle but can’t for the sake of me find it. Have some legacy Rust code (what??) that I’m hoping to slowly port to Go piece by piece
- apendleton 3y agoIf you want to call from Go into Rust, you can declare any Rust function as `extern "C"` and then call it the same way you would call C from Go. Not sure about going the other way.
- duped 3y agoIt's usually unwise to mix managed and unmanaged memory since the managed code needs to be able to own the memory its freeing and moving whereas the unmanaged code needs to reason about when memory is freed or moved. cgo (and other variants) let you mix FFI calls into unmanaged memory from managed code in Go, but you pay a penalty for it. In language implementations where GC isn't shared by the different languages calling each other you're always going to have this problem. Mixing managed/unmanaged code is both an old idea and actively researched. It's almost always a terrible idea to call into managed code from unmanaged code unless you're working directly with an embedded runtime that's been designed for it. And when you do, there's usually a serialization layer in between.
- neonsunset 3y agoMixing managed and unmanaged code being an issue is simply not true in programming in general. It may be an issue in Go or Java, but it just isn't in C# or Swift. Calling `write` in C# on Unix is as easy as the following snippet and has almost no overhead: var text = "Hello, World!\n"u8; Interop.Write(1, text, text.Length); static unsafe partial class Interop { [LibraryImport("libc", EntryPoint = "write")] public static partial void Write( nint fd, ReadOnlySpan<byte> buffer, nint length); } In addition, unmanaged->managed calls are also rarely an issue, both via function pointers and plain C exports if you build a binary with NativeAOT: public static class Exports { [UnmanagedCallersOnly(EntryPoint = "sum")] public static nint Sum(nint a, nint b) => a + b; } It is indeed true that more complex scenarios may require some form of bespoke embedding/hosting of the runtime, but that is more of a peculiarity of Go and Java, not an actual technical limitation.
- dwattttt 3y ago> If a non-polymorphic, non-inline function may have its address taken (as a function pointer), either because it is exported out of the crate or the crate takes a function pointer to it, generate a shim that uses -Zcallconv=legacy and immediately tail-calls the real implementation. This is necessary to preserve function pointer equality. If the legacy shim tail calls the Rust-calling-convention function, won't that prevent it from fixing any return value differences in the calling convention?
- JonChesterfield 3y agoYes. People tend to forget about the return half of the calling convention though so it's an understandable typographical error.
- sheepscreek 3y agoVery interesting but pretty quickly went over my head. I have a question that is slightly related to SIMD and LLVM. Can someone explain simply where does MLIR fit into all of this? Does it standardize more advanced operations across programming languages - such as linear algebra and convolutions? Side-note: Mojo has been designed by the creator of LLVM and MLIR to prioritize and optimize vector hardware use, as a language that is similar to Python (and somewhat syntax compatible).
- jcranmer 3y ago> Can someone explain simply where does MLIR fit into all of this? It doesn't. MLIR is a design for a family of intermediate languages (called 'dialects') that allow you to progressively lower high-level languages into low-level code.
- fl0ki 3y agoThe ML media cycle is so unhinged that I've seen people simply assume out of hand that MLIR stands for Machine Learning Intermediate Representation.
- fpgamlirfanboy 3y ago> Side-note: Mojo has been designed by the creator of LLVM and MLIR to prioritize and optimize vector hardware use, as a language that is similar to Python (and somewhat syntax compatible). Are people getting paid to repeat this ad nauseum?
- jadodev 3y agoMLIR includes a "linalg" dialect that contains common operations. You can see those here: https://mlir.llvm.org/docs/Dialects/Linalg/ https://mlir.llvm.org/docs/Dialects/Linalg/ This post is rather unrelated. The linalg dialect can be lowered to LLVM IR, SPIR-V, or you could write your own pass to lower it to e.g. your custom chip.
- JonChesterfield 3y agoReasonable sketch. This is missing the caller/called save distinction and makes the usual error of assigning a subset of the input registers to output. It's optimistic about debuggers understanding non-C-like calling conventions which I'd expect to be an abject failure, regardless of what dwarf might be able to encode. Changing ABI with optimization setting interacts really badly with separate compilation. Shuffling arguments around in bin packing fashion does work but introduces a lot of complexity in the compiler, not sure it's worth it relative to left to right first fit. It also makes it difficult for the developer to predict where arguments will end up. The general plan of having different calling conventions for addresses that escape than for those that don't is sound. Peeling off a prologue that does the impedance matching works well. Rust probably should be willing to have a different calling convention to C, though I'm not sure it should be a hardcoded one that every function uses. Seems an obvious thing to embed in the type system to me and allowing developer control over calling convention removes one of the performance advantages of assembly.
- khuey 3y ago> It's optimistic about debuggers understanding non-C-like calling conventions which I'd expect to be an abject failure, regardless of what dwarf might be able to encode. DWARF doesn't encode bespoke calling conventions at all today.
- LegionMammal978 3y ago> This is missing the caller/called save distinction and makes the usual error of assigning a subset of the input registers to output. Out of curiosity, what's so problematic about using some input registers as output registers? On the caller's side, you'd want to vacate the output registers between any two function calls regardless. And it occurs pretty widely in syscall conventions, to my binary-golfing detriment. Is it for the ease of the callee, so that it can set up the output values while keeping the input values in place? That would suggest trying to avoid overlap (by placing the output registers at the end of the input sequence), but I don't see how it would totally contraindicate any overlap.
- JonChesterfield 3y ago
- Animats 3y agoGiven that the current Rust compiler does aggressive inlining and then optimizes, is this worth the trouble? If the function being called is tiny, it should be inlined. If it's big, you're probably going to spend some time in it and the call overhead is minor.
- jonstewart 3y agoProbably? A complex function that’s not a good fit for inlining will probably access memory a few times and those accesses are likely to be the bottlenecks for the function. Passing on the stack squeezes that bottleneck tighter — more cache pressure, load/stores, etc. If Rust can pass arguments optimally in a decent ratio of function calls, not only is it avoiding the several clocks of L1 access, it’s hopefully letting the CPU get to those essential memory bottlenecks faster. There are probably several percentage points of win here…? But I am drinking wine and not doing the math, so…
- celeritascelery 3y agoRuntime functions (eg dyn Trait) can’t be inlined for one, so this would help there. But also if you can make calls cheaper then you don’t have to be so aggressive with inlining, which can help with code size and compile times.
- quotemstr 3y agoThe C calling convention kind of sucks. True, can't change the C calling convention, but that doesn't make it any less unfortunate. We should use every available caller-saved register for arguments and return values, but in the traditional SysV ABI, we use only one register (sometimes two) for return values. If you return a struct Point3D { long x, y, z }, you spill the stack even though we could damned well put Point3D in rax, rdi, and rsi. There are other tricks other systems use. For example, if I recall correctly, in SBCL, functions set the carry flag on exit if they're returning multiple values. Wouldn't it be nice if we used the carry flag in indicate, e.g. whether a Result contains an error.
- fch42 3y ago"sucks" is a strong word but with respect to return values, you're right. The C calling conventions, everywhere really, support what C supports - returning one argument. Well, not even that (struct returns ... nope). Kind of "who'd have thought" in C I guess. And then there's the C++ argument "just make it inline then". On the other hand, memory spills happen. For SPARC, for example, the gracious register space (windows) ended up with lots of unused regs in simple functions and a cache-busting huge stack size footprint, definitely so if you ever spilled the register ring. Even with all the mov in x86 (and there is always lots of it, at least in compiled C code) to rearrange data to "where it needed to be", it often ended up faster. When you only look at the callee code (code generated for a given function signature), it's tempting to say "oh it'll definitely be fastest if this arg is here and that return there". You don't know the callers though. There's no guarantee the argument marshalling will end up "pass through" or the returns are "hot" consumed. Say, a struct Point { x: i32, y: i32, z: i32 } as arg/return; if the caller does something like mystruct.deepinside.point[i] = func(mystruct.deepinside.point[i]) in a loop then moving it in/out of regs may be overhead or even prevent vectorisation. But the callee cannot know. Unless... the compiler can see both and inline (back to the C++ excuse). Yes, for function call chaining javascript/rust style it might be nice/useful "in principle". But in practice only if the compiler has enough caller/callee insight to keep the hot working set "passthrough" (no spills). The lowest hanging fruit on calling is probably to remove the "functions return one primitive thing" that's ingrained in the C ABIs almost everywhere. For the rest ? A lot of benchmarking and code generation statistics. I'd love to see more of that. Even if it's dry stuff.
- m463 3y agointeresting website - the title text is slanted. Sometimes people who dig deep into the technical details end up being creative with those details.
- eviks 3y agoTrue, creative, but usually in a quality degrading way like here (slanted text is harder to read, also due to the underline being too thick, and takes more space) or like with those poorly legible bg/fg color combinations
- dhosek 3y agoI just spent a bunch of time on inspect element trying to figure out how the section headings are set at an angle and (at least with Safari tools), I’m stumped. So how did he do this?
- caperfee 3y agoThe style is on the `.post-title` element: `transform: skewY(-2deg) translate(-1rem, -0.4rem);`
- aaron_seattle2 3y agoh1, h2, h3, h4, h5, h6 { transform:skewY(-2deg) translate(-1rem,0rem); transform-origin:top; font-style:italic; text-decoration-line:underline; text-decoration-color:goldenrod; text-underline-offset:4%; text-decoration-thickness:.25ex }
- skgough 3y agorelated, I thought the minimap was using the CSS element() function [0], but it turns out it's actually just a copy of the article shrunk down real small. [0] https://developer.mozilla.org/en-US/docs/Web/CSS/element https://developer.mozilla.org/en-US/docs/Web/CSS/element
- deleted 3y ago[deleted]
- pizlonator 3y agoThe main thing you want to do when optimizing the calling convention is measure its perf, not ruminate about what you think is good. Code performs well if it runs fast, not if it looks like it will. Sometimes, what the author calls bad code is actually the fastest thing you can do for totally not obvious reasons. The only way to find out is to measure the performance on some large benchmark. One reason why sometimes bad looking calling conventions perform well is just that they conserve argument registers, which makes the register allocator’s life a tad easier. Another reason is that the CPUs of today are optimized on traces of instructions generated by C compilers. If you generate code that looks like what the C compiler would do - which passes on the stack surprisingly often, especially if you’re MSVC - then you hit the CPU’s sweet spot somehow. Another reason is that inlining is so successful, so calls are a kind of unusual boundary on the hot path. It’s fine to have some jank on that boundary if it makes other things simpler. Not saying that the changes done here are bad, but I am saying that it’s weird to just talk about what looks like weird code without measuring. (Source: I optimized calling conventions for a living when I worked on JavaScriptCore. I also optimized other things too but calling conventions are quite dear to my heart. It was surprising how often bad-looking pass-on-the-stack code won on big, real code. Weird but true.)
- mkj 3y agoAnd remember that performance can include binary size, not just runtime speed. Current Rust seems to suffer in that regard for small platforms, calling convention could possibly help there wrt Result returns.
- fleventynine 3y agoThe current calling convention is terrible for small platforms, especially when using Result<> in return position. For large enums, the compiler should put the discriminant in a register and the large variants on the stack. As is, you pay a significant code size penalty for idiomatic rust error handling.
- planede 3y ago
- vrotaru 3y agoThere was an interesting aproach to this, in an experimental language some time ago fn f1 (x, y) #-> // Use C calling conventions fn f2 (x, y) -> // use fast calling conventions The first one was mostly for interacting with C code, and the compiler knew how to call each function.
- dgellow 3y agoIs it similar to Zig’s callconv keyword?
- vrotaru 3y agoGuess so. Unfamiliar with Zig. The point is that not a "all or nothing" strategy for a compilation unit. Debugger writers may not be happy, but maybe lldb supports all conventions supported by llvm.
- int_19h 3y agoDebugger writers have dealt with different calling conventions for decades. The notion predates C even. They can handle it just fine.
- magicalhippo 3y agoDelphi, and I'm sure others, have had[1] this for ages: When you declare a procedure or function, you can specify a calling convention using one of the directives register, pascal, cdecl, stdcall, safecall, and winapi. As in your example, cdecl is for calling C code, while stdcall/winapi on Windows for calling Windows APIs. [1]: https://docwiki.embarcadero.com/RADStudio/Sydney/en/Procedures_and_Functions_(Delphi) https://docwiki.embarcadero.com/RADStudio/Sydney/en/Procedur...
- pjmlp 3y agoSince the Turbo Pascal days actually.
- zamalek 3y ago> Debuggers Simply throw it in as a Cargo.toml flag and sidestep the worry. Yes, you do sometimes have to debug release code - but there you can use the not-quite-perfect debugging that the author mentions. Also, why aren't we size-sorting fields already? That seems like an easy optimization, and can be turned off with a repr.
- JonChesterfield 3y agoDid you want alignment sorting? In general the problem with things like that is the ideal layout is usually architecture and application specific - if my struct has padding in it to push elements onto different cache lines, I don't want the struct reordered.
- zamalek 3y ago> Did you want alignment sorting? Yep. It will probably improve (to be measured) the 80%. Less memory means less bandwidth usage etc. > if my struct has padding in it to push elements onto different cache lines, I don't want the struct reordered. I did suggest having a repr for situations like yours. Something like #[repr(yeet)]. Optimizing for false sharing etc. is probably well within 5% of code that exists today, and is usually wrapped up in a library that presents a specific data structure.
- fl0ki 3y ago> Also, why aren't we size-sorting fields already? We are for struct/enum fields. https://camlorn.net/posts/April%202017/rust-struct-field-reordering/ https://camlorn.net/posts/April%202017/rust-struct-field-reo... There's even an unstable flag to help catch incorrect assumptions about struct layout. https://github.com/rust-lang/compiler-team/issues/457 https://github.com/rust-lang/compiler-team/issues/457
- zamalek 3y agoOh wow, awesome! I was somewhat considering this being my first contribution - glad someone already tackled it!
- repelsteeltje 3y agoCan someone explain the “Diana’s silk dress cost $89” mnemonic on x86 reference?
- deleted 3y ago[deleted]
- pipo234 3y agohttps://csappbook.blogspot.com/2015/08/dianes-silk-dress-costs-89.html https://csappbook.blogspot.com/2015/08/dianes-silk-dress-cos...
- repelsteeltje 3y agoHah thanks! Suppose I should have know that one, but never did meaningful assembly programming beyond 6502 and ARM32.
- tempodox 3y agohttps://www.contrastrebellion.com https://www.contrastrebellion.com
- Arnavion 3y agoTangentially related, there's another "unfortunate" detail of Rust that makes some structs bigger than you want them to be. Imagine a struct Foo that contains eight `Option<u8>` fields, ie each field is either `None` or `Some(u8)`. In C, you could represent this as a struct with eight 1-bit `bool`s and eight `uint8_t`s, for a total size of 9 bytes. In Rust however, the struct will be 16 bytes, ie eight sequences of 1-byte discriminant followed by a `uint8_t`. Why? The reason is that structs must be able to present borrows of their fields, so given a `&Foo` the compiler must allow the construction of a `&Foo::some_field`, which in this case is an `&Option<u8>`. This `&Option<u8>` must obviously look identical to any other `&Option<u8>` in the program. Thus the underlying `Option<u8>` is forced to have the same layout as any other `Option<u8>` in the program, ie its own personal discriminant bit rounded up to a byte followed by its `u8`. The struct pays this price even if the program never actually constructs a `&Foo::some_field`. This becomes even worse if you consider Options of larger types, like a struct with eight `Option<u16>` fields. Then each personal discriminant will be rounded up to two bytes, for a total size of 32 bytes with a quarter (or almost half, if you include the unused bits of the discriminants) being wasted interstitial padding. The C equivalent would only be 18 bytes. With `Option<u64>`, the Rust struct would be 128 bytes while the C struct would be 72 bytes. You *can* implement the C equivalent manually of course, with a `u8` for the packed discriminants and eight `MaybeUninit<T>`s, and functions that map from `&Foo` to `Option<&T>`, `&mut Foo` to `Option<&mut T>`, etc, but not to `&Option<T>` or `&mut Option<T>`. https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=e4f74ba7636f589acc5e89e53ad0d173 https://play.rust-lang.org/?version=stable&mode=debug&editio...
- Arcuru 3y agoYou have to implement the C version manually, so it's not that odd you'd need to do the same for Rust? You've described, basically, a custom type that is 8 Options<u8>s. If you start caring about performance you'll need to roll your own internal Option handling.
- Arnavion 3y ago>You have to implement the C version manually There's no "manually" about it. There's only one way to implement it in C, ie eight booleans and eight uint8_ts as I described. Going from there to the further optimization of adding a `:1` to every `bool` field is a simple optimization. Reimplementing `Option` and the bitpacking of the discriminants is much more effort compared to the baseline implementation of using `Option`.
- jayachandranpm 3y agogood