Propose Rust backend design#268
Conversation
EricRahm
left a comment
There was a problem hiding this comment.
Love this, thanks for drafting it up! I didn't do a super deep review, but just added some conversation starter questions.
| * If the storage is too small to read the presence condition: returns `Err(Error::OutOfBounds)`. | ||
| * If the condition is successfully read and evaluates to `false`: returns `Ok(None)` (even if the storage is too small to hold the field itself). | ||
| * If the condition is `true` and storage is valid for the field: returns `Ok(Some(FieldView))`. | ||
| * If the condition is `true` but storage is too small for the field: returns `Err(Error::OutOfBounds)`. |
There was a problem hiding this comment.
Do we want to distinguish this somehow from "too small to read condition"?
There was a problem hiding this comment.
I don't know that there's much use in doing that, in both cases its "you need more storage or you haven't seen the whole packet yet" so the remedy should be the same.
jasongraffius
left a comment
There was a problem hiding this comment.
Thanks, added some comments, I'll update to cover some of the questions here
4fcf660 to
22ff619
Compare
reventlov
left a comment
There was a problem hiding this comment.
Overall, I'm excited to see this, especially since I recently started work on a primarily-Rust project.
| * When a nested writer is accessed, the parameters are propagated implicitly because the `NestedStorage` holds the parent writer (which contains the parameters) by value. | ||
|
|
||
| #### 8. Alignment and LLVM Optimizations | ||
| Unlike C++ which tracks buffer alignment in the template types, the Rust backend delegates alignment optimization to the compiler. |
There was a problem hiding this comment.
First, I agree with the decision to skip over Aligned reader/writer types, at least for now: they add complication to end user code that is rarely worth it in practice: the only widely-used target with alignment requirements that I know of, in 2026, is Web Assembly, which has minimal overlap with Emboss's uses.
(There are still a few PowerPC, MicroBlaze, and MIPS processors in use, but they're much more niche. Plus, the vast majority of uses are totally fine with the slight performance hit of alignment-neutral code even on platforms where it matters.)
(OK, so technically there are a few x86 instructions that have alignment requirements, but I think no current compilers emit them, and their unaligned counterparts have nearly the same speed on the last few generations of x86 processors.)
The justification for having Aligned at all on the C++ side is to hit the marketing benchmark of "equal the performance of packed structs." (Which also requires using the Unsafe methods.)
Second, rustc cannot do any better than clang or gcc, for the same reasons: the compiler cannot always infer the strictest alignment of the base address of a view. In C++, the Aligned view types let the programmer force the compiler to assume a certain base alignment, even if that base alignment is not deducible. (Generally, the base alignment is not deducible when receiving a pointer from some external source, and that is true with &[u8] just as much as char *.)
There was a problem hiding this comment.
Ah got it, that's a good point, I'll fix up the wording here, but yeah for a first pass we probably want to skip the Aligned stuff for now. FWIW I believe that an AlignedStorage would be implementable by a client as a Storage implementation and should work correctly after monomorphization.
| A Rust backend for Emboss should satisfy the following requirements: | ||
|
|
||
| 1. **Zero-copy**: Accessing fields should not require copying data into intermediate structures. | ||
| 2. **Efficiency**: Generated code should compile to efficient machine code, equivalent to hand-written unsafe pointer manipulation but with safety checks. |
There was a problem hiding this comment.
I think I would not make this level of efficiency a first-version requirement. Real-world experience, including on microcontrollers, is that 99% of Emboss-using code does not need absolute maximum performance. Better to get something that covers most cases first, and only then start taking on the few ultra-performance-critical bits.
Also, in many cases, "absolute maximum performance" means that the end developer has to provide hints (and in Rust, probably go unsafe).
See my comments below about the proposed type states and about C++ Aligned view types.
There was a problem hiding this comment.
Fair point, that's true, it's not quite the same level of efficiency. When experimenting, I did try to get to the point that properly used generated code compiled down in assembly to equivalent hand-written implementation, but yeah there's lots of edge cases this would get bogged down on if we needed to actually hit that level across the board. I'll update to clarify!
| .into_struct(); // Consumes writer, returns the base struct view | ||
| ``` | ||
|
|
||
| ### Compile-Time State Tracking (Typestates) |
There was a problem hiding this comment.
My high-level comment about this section is that I would plan to launch with only (the equivalent of) UncheckedState, and then worry about adding more states (and more complication to end user code) later, and only in cases where the performance really matters. Especially since this all falls under the umbrella of "could be done automatically by a sufficiently smart compiler."
There was a problem hiding this comment.
The main reason I came up with the typestate stuff here is because a common paradigm I've seen used for Emboss stuff is "check complete, then write stuff (unchecked or panicking, not error checking), then check okay" not realizing an earlier write could make a later write invalid (either silent UB for the unchecked or crash for panic-checked).
That said, it is technically an optimization, only offering the try_write and try_read fallible options would be a good starting point. I think I'll reconfigure this to just launch with the try_ ops without a typestate, and offer the stateful views as a convenience later.
There was a problem hiding this comment.
Another reason I liked the typestate is that you can declare your requirements "This function requires a fully complete and validated XyzStruct" means a lot less boilerplate and missed/forgotten checks
There was a problem hiding this comment.
Yeah, just to be clear, I'm not saying "this is a bad idea" or "don't do this;" I'm only suggesting that you defer it until you have something working at all.
There was a problem hiding this comment.
Yep yep! In agreement here, I'm gonna reconfigure this doc to move this to a future work section.
|
|
||
| A view or writer can be in one of several states: | ||
| * `UncheckedState`: Initial state. Fields cannot be accessed directly without runtime bounds checks. Read/write accessors in this state perform dynamic bounds checking on the fly. | ||
| * `MinimallyCompleteState`: The buffer is known to be at least large enough for the minimum size of the structure. |
There was a problem hiding this comment.
In terms of access patterns, this state seems mostly unnecessary? It would only allow unchecked access to a somewhat arbitrary set of fields (i.e., those that are static + those that are close enough to the start of a "union" that they're always covered).
That starts getting into "how much do you want to explode the state space?". You could theoretically get very, very fine-grained.
There was a problem hiding this comment.
Yeah, I figured it was a happy medium between fine-grained explosion and fully coarse, like a common case of access to a known-good fixed header or similar. Honestly this is basically me trying to get Rust to work like a dependently typed system in a way... I'm inclined to keep it as it doesn't add much complexity itself, but you're right there does need to be a fairly arbitrary "where do you stop" line if you add something like this.
|
|
||
| ### Compile-Time State Tracking (Typestates) | ||
|
|
||
| To avoid runtime overhead while ensuring safety, we propose using a typestate pattern to track the validation and completeness status of a view at compile time. |
There was a problem hiding this comment.
A DynamicSizeIsKnownState might be useful here, as it would let you elide bounds and existence checks when reading fields for size computations.
There was a problem hiding this comment.
This was basically my intent with something like MinimallyComplete, but now that I think about it, the dynamic size may require more than just MinimallyComplete (I'm considering a jagged 2D array where each array embeds its length, which is dependent on an outer length)
| 2. **Efficiency**: Generated code should compile to efficient machine code, equivalent to hand-written unsafe pointer manipulation but with safety checks. | ||
| 3. **Safety**: The generated API must be memory-safe. Any use of `unsafe` must be internal to the runtime, minimized, and thoroughly verified. | ||
| 4. **Idiomatic Rust**: The API should feel natural to Rust developers, utilizing features like `Result`, `Option`, and traits. | ||
| 5. **`no_std` Support**: The runtime and generated code must support `no_std` environments for use in embedded systems or kernels. |
There was a problem hiding this comment.
I don't recall whether no_std implies "no heap memory allocation," but if not it seems like that would be important to add here.
Also, similar to C++, I would only support no_std for "core" functionality, and let things like text serialization/deserialization require std.
There was a problem hiding this comment.
Yeah it's somewhat orthogonal, but no alloc is also my intent, I'll add that.
And yeah, agreed, I'll update this to clarify that "no_std support" doesn't mean we can't have things that require no_std just there must exist a configuration that supports no_std.
| * `SHOUTY_CASE` for enum values and constants maps to Rust `SHOUTY_CASE` constants. | ||
|
|
||
| #### Naming Conflict Resolution | ||
| Emboss structures can have fields with arbitrary names. To avoid naming conflicts between generated field accessors and built-in helper methods (like `size_in_bytes` or `storage`), helper methods are defined on traits (e.g., `emboss_runtime::Struct`). |
There was a problem hiding this comment.
Not quite arbitrary: most Rust keywords are banned. However, the keywords added in the 2018 and 2024 editions are not banned, so you will need a strategy to handle them.
(You'd have to look up the internal change history to figure out when I actually made that list, but I feel it might have been before Rust 1.0? It's missing async, await, and dyn, where were reserved but unused sometime before the 2018 edition, and it has alignof, which seems weird.)
There was a problem hiding this comment.
Ah yes, I actually meant to include that, we want to carve out keywords that aren't in the banlist for r#keyword treatment, the only ones you can't use that for are covered by the banlist.
| 3. **Safety**: The generated API must be memory-safe. Any use of `unsafe` must be internal to the runtime, minimized, and thoroughly verified. | ||
| 4. **Idiomatic Rust**: The API should feel natural to Rust developers, utilizing features like `Result`, `Option`, and traits. | ||
| 5. **`no_std` Support**: The runtime and generated code must support `no_std` environments for use in embedded systems or kernels. | ||
|
|
There was a problem hiding this comment.
These are more of policy questions than design questions, but they have some design implications:
What versions of Rust are you planning to support, and what is the plan for how that list changes over time? I.e., what will you support "at launch," and what is the Rust equivalent of "until 10 years after the release of the following standard" on the C++ side?
My gut feeling is that Rust users are a lot less likely to be tied to ancient toolchains than embedded C++ users, so a much shorter (say, 3-5 year) support window may be fine. Also, it's fine to only target, say, the Rust 2025 edition at launch, although I don't think I saw anything in here that needs anything that modern.
| * `UncheckedState`: Initial state. Fields cannot be accessed directly without runtime bounds checks. Read/write accessors in this state perform dynamic bounds checking on the fly. | ||
| * `MinimallyCompleteState`: The buffer is known to be at least large enough for the minimum size of the structure. | ||
| * `CompleteState`: The buffer is large enough for the current dynamic layout of the structure. Always-present fields can be read/written without runtime bounds checks. | ||
| * `AlwaysCompleteState`: The buffer is large enough for the maximum possible size of the structure. |
There was a problem hiding this comment.
Note that AlwaysCompleteState does not imply that sub-structures are actually complete, so any field access for a dynamically-sized field puts you back into UncheckedState. Lots of real-world messages have degenerate encodings where a "payload length" field can have a value that is too short for the actual payload.
There was a problem hiding this comment.
I'm trying to think of the implications of this... like a struct containing a sub-struct in a payload, that sub-struct is sized dynamically by some other field, and therefore IsComplete is false. Am I getting that right?
There was a problem hiding this comment.
Doesn't have to be that complicated. If you have:
struct Message:
# ... fields ...
8 [+2] MessageType message_type (mt)
10 [+2] UInt message_length (len)
if mt == MessageType.Foo:
12 [+len] Foo foo
if mt == MessageType.Bar:
12 [+len] Bar bar
# ... etc ...
When message_length is 0, you can have infinite bytes available in Message's storage, but foo/bar/etc. will never be complete.
There was a problem hiding this comment.
Right right, that's the same scenario, no? Foo/Bar as the substruct, len as the dynamic size by some other field, the inner structs can be incomplete while the outer is complete.
But yeah, that's a good point this should be called out, in the typestate model, dynamically sized sub-structs need to have their state downgraded on access.
There was a problem hiding this comment.
I may have misread what you said; I thought you were saying that one more level of wrapping was needed. (Like that Foo needed to have a dynamically-sized sub-structure.)
|
|
||
| #### 6. Dynamic Writes (State-Stable Structs) | ||
|
|
||
| For complex imperative updates where the developer wants to avoid typestate transitions entirely, they can use the writer in `UncheckedState` directly. A struct or writer in a state-stable typestate (such as `UncheckedState` or `MinimallyCompleteState`) is referred to as a **state-stable struct** (or state-stable writer). |
There was a problem hiding this comment.
To avoid confusion, I would not call the read-only types structs: they do not hold their data inline the way that an actual struct does. I would either stick with "view" (by analogy with relational database views) and disambiguate with "real-only view" and "read-write view," or consistently use "reader" and "writer."
There was a problem hiding this comment.
By making the storage generic, I was hoping to actually permit the "carries storage with it" style structs. If you have Simple6ByteStruct::new([u8; 6]), for instance, that would give you a Simple6ByteStruct<[u8; 6]>. So the difference between a view and owning is whether the storage is a view or owning, and the struct does "own" its storage, just that that storage may itself be a borrow or other referential type.
There was a problem hiding this comment.
I see. That's a good goal, but I would still avoid the name "struct" for the generic type; let someone make a type Simple6ByteStruct = Simple6ByteSomething<[u8; 6]>; alias for the inline storage version.
Proposal and design for a Rust backend for Emboss