5 ms·
This is pretty cool! A few thoughts from a C/C++ perspective (I have only tinkered briefly with Rust): - This effectively makes the optimization algorithm par
by mockery 9y ago
This is pretty cool!
A few thoughts from a C/C++ perspective (I have only tinkered briefly with Rust):
- This effectively makes the optimization algorithm part of the Rust ABI; you cannot change (or even fix a bug in) the layout algorithm without breaking binary compatibility. The simpler the algorithm, the less of a concern this would be.
- In a systems programming context, you don't always want to optimize a struct for size. You may reorder a large struct to optimize for other properties like cache coherency, or because you have to match a memory layout specified by other software or even hardware. Obviously one can still use 'repr(C)' for these cases.
- Related to the prior point, it's a little disconcerting that adding a field to the end of a struct declaration can completely change the layout. Sometimes in C/C++ land programmers 'cheat' by adding fields to the end of a struct that's used in multiple modules, and initially only recompile the modules that need to know about the new field. (For purposes of compilation expediency/iteration speed.)
- This seems like it may violate the principle of least-surprise pretty badly. As long as we continue to use debuggers and have crash dumps, systems programmers will sometimes need to look at raw memory data and figure out what the structure is. If the field layout isn't easily predicted, that may be very difficult. In my experience there can be huge benefits to minimizing the number of opaque/hard-to-predict transformations between authored data and its final representation. (In this case, the struct declaration and its final memory layout.)
For these reasons it seems like this auto-reorder behavior might be better as an opt-in rather than opt-out behavior (which it sounds like it is.) Regardless, it's still a feature I wish I could have in C++ from time to time!
- oconnor663 9y ago> This effectively makes the optimization algorithm part of the Rust ABI; you cannot change (or even fix a bug in) the layout algorithm without breaking binary compatibility. I think the official word is that Rust has no stable ABI, unless you use repr(C) to force the C ABI?
- kibwen 9y agoIndeed, it's generally assumed that no two versions of the compiler are binary-compatible. ABI stability may come someday, but it's not a high priority at the moment.
- CAMLORN 9y agoRust doesn't currently guarantee ABI compatibility. You can't load Rust from a shared library without going via repr(C) anyway. I imagine that the algorithm will be frozen whenever we get to this point, but it shouldn't need to change again unless someone tries to take us as far as what Swift does (which would potentially be a much bigger project than this). There's also been discussion of giving you tools to control it more explicitly, but thus far no one has proposed an RFC.
- phkahler 9y ago>> Rust doesn't currently guarantee ABI compatibility. And yet they claim it's a systems programming language. While I can see the value in doing this, I can see the value in doing it manually. Another criteria can be putting the most frequently used fields together, so accessing one causes the others to load in the same cache line. In many programs the size of the structure is not important, it's the cache behavior that is and that is dependent on a lot more than the size of the elements alone. It's probably important that it can be disabled with rear(C).
- kibwen 9y ago> And yet they claim it's a systems programming language. C++ also doesn't guarantee a stable ABI, and even the C ABI is only de facto standardized on various platforms, nothing is provided by the language standard itself.
- dom0 9y agoC and C++ ABI is implementation defined. All major platforms have a standard ABI for both, although sometimes it's broken intentionally. E.g. MinGW uses a GCC/Linux-like C++ ABI and not the MSVC C++ ABI. Note that "platform" means e.g. Linux-x86-64 and not Linux. Linux-ppc has different rules, for example.
- jjnoakes 9y agoNot true for c++ unless you include the compiler and version in your "platform".
- kibwen 9y ago> Sometimes in C/C++ land programmers 'cheat' by adding fields to the end of a struct that's used in multiple modules, and initially only recompile the modules that need to know about the new field. (For purposes of compilation expediency/iteration speed.) How would that work, with some modules assuming an incorrect size for a given type? I agree with your concerns regarding debuggers and crash dumps, though in my experience you need debuggers far less often when writing Rust code, and when you do need them (e.g. for unsafe code) you've often already used `repr(C)` on the types of interest.
- eddyb 9y agoThe correct type layout is already encoded in debuginfo, so I'm not sure how that's a problem unless you're avoiding a debugger intentionally or simply can't use one?
- mockery 9y ago> How would that work, with some modules assuming an incorrect size for a given type? If they only consume pointers to that type (ie. never allocate it themselves, deal with arrays, etc.) and their notion of the memory layout is a prefix-identical subset of the 'real' type, then they can't tell the difference. And sometimes that's enough to get by while you test a new feature! A similar trick is initially adding new virtual functions to the very end of a C++ class declaration (regardless of where in the declaration they 'should' be for readability etc.) so the vtable is prefix-compatible with other modules that you haven't recompiled yet. If you work in a large C++ codebase with lots of DLLs, it's a dirty trick that can come in very handy. The memory prefix trick is an ad-hoc version of the more 'legitimate' approach of using inheritance to expose only part of the memory layout of a public API: struct PublicStruct { /* ... */ }; // shared as part of the API details struct InternalImpl: public PublicStruct { /* ... */ }; An implementing module only ever instantiates InternalImpl but returns PublicStruct* pointers to the outside world. (Only works if the 'implementing' module is the only thing allowed to allocate PublicStruct.) You could also do something similar in C without inheritance by making the first member of InternalImpl a value of type PublicStruct. This is also related to other memory tricks like storing a null-terminated string's length (or hash) in the memory before the address of its first character, or how most malloc implementations have a per-allocation bookkeeping header that lives in the memory immediately preceding the address returned to their caller. There are countless cases in systems programming where programmers do sneaky things with memory (for better or worse...)
- Manishearth 9y ago> Sometimes in C/C++ land programmers 'cheat' by adding fields to the end of a struct that's used in multiple modules, and initially only recompile the modules that need to know about the new field. (For purposes of compilation expediency/iteration speed.) Rust doesn't compile that way -- you can't compile individual modules at once, only the entire crate. Incremental compilation is supposed to help detect intra-crate dependencies so that you don't have to compile as much, but this trick wouldn't be applicable in the incremental compilation cases too since you can't manually drive that either. The nature of crates means that you can rely more on inlining happening and a whole host of other optimizations. Additionally, optimizations like this make more sense when the compilation unit boundary is the crate. > As long as we continue to use debuggers and have crash dumps, systems programmers will sometimes need to look at raw memory data and figure out what the structure is. As long as you're using a debugger, it's fine -- we still give the correct debuginfo. The presence of ADTs in Rust mean that the layout of many types isn't immediately obvious without debuginfo anyway. Of course you may be in a situation where you can't rely on the debuginfo (stripped binary or something?), in which case this will be annoying. But it's really a similar situation as you have with inlining when you don't have debuginfo.
- mockery 9y ago> Rust doesn't compile that way -- you can't compile individual modules at once, only the entire crate. I think we have a terminology mixup. I was using 'module' in the win32 LoadModule() sense: a shared dynamically loaded library (ie. a .DLL in windows or .SO in linux.) I'm not sure how Rust crates (or other compilation units) map to those - my guess would be that a given crate will be compiled into (in win32 terms) a .exe .lib or .dll I /think/ the Rust equivalent of the case I'm describing would be that you have a struct that's part of the public API of a crate, and it's being used across multiple crates in a large project where you don't want to fully recompile the world in order to test your changes. > Of course you may be in a situation where you can't rely on the debuginfo (stripped binary or something?), in which case this will be annoying. But it's really a similar situation as you have with inlining when you don't have debuginfo. In my C++ experience there end up being plenty of cases where it's really useful to be able to inspect raw memory (ie. hex dump, with no debugger or without enough context for the debugger to help you) and figure out what was going on. Obviously Rust is designed to dramatically reduce the frequency of that kind of debugging, but to me this still feels more like a simple-vs-easy trade off [1] than a strict win. > The presence of ADTs in Rust mean that the layout of many types isn't immediately obvious without debuginfo anyway. Pardon my Rust ignorance, but is this scenario significantly different from C++ templates? The layout of a (judiciously) templated C++ class may not be "immediately obvious" but in practice it's often still very straightforward to infer. [1] https://www.infoq.com/presentations/Simple-Made-Easy https://www.infoq.com/presentations/Simple-Made-Easy
- pcwalton 9y ago> - This effectively makes the optimization algorithm part of the Rust ABI; you cannot change (or even fix a bug in) the layout algorithm without breaking binary compatibility. There would be lots of problems if we tried to standardize the ABI; we would be forever locked into all sorts of decisions that would be impossible to change. For instance, we'd be unable to fix bugs in much of the standard library. This why, for example, C++ does not standardize the ABI. > You may reorder a large struct to optimize for other properties like cache coherency Optimizing for size is much more commonly desired than manually cache-aligning things. Optimizing for manual cache alignment would be the wrong default. > or because you have to match a memory layout specified by other software or even hardware. Obviously one can still use 'repr(C)' for these cases. In fact you always had to use `repr(C)` for those cases. It's just that it wasn't enforced before. > Sometimes in C/C++ land programmers 'cheat' by adding fields to the end of a struct that's used in multiple modules, and initially only recompile the modules that need to know about the new field. This only works if you throw type/memory safety out the window. In a language like Rust that tries to encourage memory safety, that wouldn't make sense. > - This seems like it may violate the principle of least-surprise pretty badly. As long as we continue to use debuggers and have crash dumps, systems programmers will sometimes need to look at raw memory data and figure out what the structure is. Rust data type layout has always been non-obvious in various ways, because of generics, zero-sized types, the various enum optimizations, etc. Not to mention that LLVM can already split apart structures on the stack in C, C++, and Rust. > For these reasons it seems like this auto-reorder behavior might be better as an opt-in rather than opt-out behavior (which it sounds like it is.) In C++, maybe. In Rust, it's a different story, and I'm confident we made the right decision.
- eddyb 9y ago> It's just that it wasn't enforced before. To be clear, this "enforcing" is now the fact that your fields, if you forgot #[repr(C)], might be in a different order than what you assume. But it has been linted for a very long time, at least in the direct interactions with FFI, you'd have to be casting pointers to/from C to hide that you're using a Rust struct for C data, from that lint.
- cryptonector 9y agoSaying this is a "C" thing is a bit silly. And it's not exactly like C struct packing rules are particularly burdensome cognitively. They're not. The only real question is whether packing should be the default (Rust) or optional (e.g., Pascal). Suppose you're just writing a struct to a file, which you'll read back later. It can't be the case that later you might fail because you got recompiled with an updated struct packing algorithm in the compiler. Sure, you might say "use an encoder", but if you don't need to be concerned with byte ordering, for example, then writing a struct as-is is the simplest and fastest encoding, and this is a totally legitimate design. Any time you need stability, you need something like C struct packing rules. IMO C struct packing should be the default, as that is safer. Though I can live with it being optional.
- kbenson 9y ago> Any time you need stability, you need something like C struct packing rules. It sounds like any time you need interoperability you need C strict packing rules. That interoperability might be with the filesystem, but it's still interaction with non-rust. I think what's needed is a set of routines to reorganize data to and from rust and C representations, with passing an already converted type as a non-op. That gives people the opportunity to choose repr(C) at development if they need to interop with C or want to optimize for cache access, or to convert to-and-from on the fly if they just want to store to disk in a stable format occasionally but still want to reap the benefits of lower memory usage in normal operation. That seems like it would be the best of all worlds. Oh, and a core library with struct packing routines for every version would be very useful to get out of those tight spots where some dev accidentally stored rust packed data to disk and found out about their problem after the fact. Being able to call RustRepr::rust1_16_to_C() or RustRepr::rust1_16_to_rust() (for current algorithm) in Rust 1.45 when the algorithm changed in 1.42 would be really useful...
- CAMLORN 9y agoI'd agree with you here, but Rust is powerful enough to abstract serialization behind a macro. You'd probably be using something like Serde instead of writing your own decoder. The macro just adds repr(C) for you and does it, if that's the design it wants to use. Rust's capabilities for abstracting in this way are still expanding, so if it can't today then it probably can tomorrow. Nonetheless, other things which aren't stable include the discriminant values of an enum and whether/if we use the null pointer optimizations. And probably others. Doing this to any Rust type is not safe as-is, and guaranteeing an ABI for all types just freezes the algorithm, as I've said elsewhere in this thread. SO I'm not seeing how this is a particular problem.