Why Doesn't Rust Use a Single Byte for Enum Tags When Possible?

I've noticed that Result<Enum_, Enum_> takes up 2 bytes of memory, even though it seems like it could be optimized to use just 1 byte. For example:

enum Enum_ {
    A,
    B,
}

fn main() {
    println!("Size of Enum_: {} bytes", std::mem::size_of::<Enum_>()); // 1 byte
    println!("Size of Result<Enum_, Enum_>: {} bytes", std::mem::size_of::<Result<Enum_, Enum_>>()); // 2 bytes
}

Since Enum_ only has two variants, A and B, it occupies 1 byte. But when wrapped in a Result, the size doubles to 2 bytes. Theoretically, couldn't we represent all possible states of Result<Enum_, Enum_> using a single byte like this:

  • Ok(Enum_::A) = 0
  • Ok(Enum_::B) = 1
  • Err(Enum_::A) = 2
  • Err(Enum_::B) = 3

This would only require 1 byte of memory, yet Rust's current implementation uses 2 bytes. Could someone explain why Rust doesn't optimize this case to use only 1 byte?

A straightforward approach is to set the tag to the identifier of the selected enum variant and add the counts of all preceding enum variants.

Given that a straightforward and safe approach could be to set the tag to the chosen enum variant's identifier and add the counts of all previous enum variants, why doesn't Rust use this method for enum representation? What are the trade-offs or reasons behind Rust's current implementation?

1 Like

The problem is that you can take a reference to the contents of the Result, and in that case one of the variants will have the wrong value for Enum_. Consider for example this snippet, which compiles:

fn break_optimization(result: &mut Result<Enum_, Enum_>) {
    match result {
        Ok(e) => set_to_a(e),
        Err(e) => set_to_a(e),
    }
}

fn set_to_a(e: &mut Enum_) {
    // Would this write 0 or 2?
    *e = Enum_::A;
}

set_to_a has no knowledge of the fact that e points inside a Result<Enum_, Enum_>, so it can't possibly know whether to write the byte for Enum_::A in an Ok or the one in an Err.

13 Likes

Thank you. But as a food of thought would this work if Result enforce immutability? (At least at the tag for unions) and does the compiler optimize in the release code if result is not Mutually borrowed.
And again, thank you for your help.

You would have to enforce no references at all, because reading a reference also needs to see the same byte representation regardless of where the reference points. At that point, Result would be difficult to use in many normal Rust ways.

(But if you enforce that (say by wrapping a struct with a private field around the enum), then you can already do this re-mapping manually.)

3 Likes

While this is a correct explanation for Result<Enum_, Enum_>, it's worth noting that unfortunately the size optimization also doesn't apply to a case where the two enums have disjoint discriminants. [playground]

pub enum E1 { A = 0, B = 1 }
pub enum E2 { C = 2, D = 3 }

const _: () = assert!(std::mem::size_of::<Result<E1, E2>>() == 2);

You can call this a missed optimization, but you can also make an argument that this is a reasonable decision to make distinguishing Ok(_) vs Err(_) faster.

None of the optimizations done by --release change the layout of types, although how that data is created and manipulated can be changed around so long as your program still behaves as if it weren't. Different versions of the compiler may of course change the layout decisions.

There are ways the compiler might modify the layout of a type between compilations, but those are either to make layout deliberately worse to find bugs that rely on unspecified layout (-Zrandomize-layout) or highly theoretical and limited (PGO reordering fields).

The concept of move-only fields has been mentioned before and would enable this niche optimization, but it wouldn't ever apply to Result, if it's ever available.

1 Like

I don't think this is the sort of thing you were talking about, but just to clarify in case someone gets the wrong idea: rustc has changed their layout algorithm in the past and reserves the right to do so in the future.

3 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.