Hello Rust Community,
First of all: Thanks for your effort with this incredible language. I've been using Rust for quite some time now and am still amazed by its beauty.
While developing a new programming language, I encountered a problem that surprised me quite a bit, and I think it might be relevant for other projects as well.
Initially, I was very happy with the performance of my interpreter:
for i = 0 to 10_000_000 {
x = 1
}
Elapsed: 66.3538ms
Then I worked on other parts of the project. When I later ran the same test again, the runtime was suddenly drastically slower:
for i = 0 to 10_000_000 {
x = 1
}
Elapsed: 144.8416ms
This confused me because I hadn't changed anything in the interpreter itself. After extensive debugging, I finally found the reason: a change to the enum that represents variables in the interpreter.
Before:
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
enum Var {
Void,
Int(i32),
Float(f32),
...
}
Then I added a new variant:
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
enum Var {
Void,
Int(i32),
Float(f32),
Array(Vec<Var>), // New variant
...
}
Although I hadn't even used this new variant in the interpreter logic, its mere existence caused the performance to drop significantly. At first, I thought cloning the vector might be the problem, but it turned out that it had nothing to do with cloning.
So what was the problem?
When I store a variable, it looks like this:
stack[var_idx] = y.clone();
I'm using clone()
here to make copies explicit, as Var
is quite large, making copying expensive.
If a variant of Var
implicitly implements Drop
, Rust must check on every assignment whether the old value implements Drop
to release memory if necessary. That is, after introduction of the array variant the code above effectively compiles to something like:
match stack[var_idx] {
Var::Array(v) => v.drop(), // Drop is called for the `Array` variant
_ => {},
}
stack[var_idx] = y.clone();
Even though in my case only the Var::Int
variant was used, the new Var::Array
variant caused Rust to always perform the drop check. This is correct behavior, but it wasn't immediately obvious to me.
This problem also occurs when an enum contains a variant that contains a struct, which in turn contains an enum that has a variant with Drop
, making it even harder to track down. A practical example:
enum Var {
...
Function(Function),
}
struct Function {
result_type: Type,
...
}
enum Type {
...
ComplexType(Box<ComplexType>) // We use Box to save memory
}
Due to the Box
in Type
, all assignments of Var
and all situations where a Var
becomes invalid suddenly become very slow. Instead of the expected acceleration due to the more compact type, the program slows down because of the invisible drop checks.
In my opinion, this behavior somewhat collides with the "principle of least surprise" and the zero-overhead principle ("You don't pay for what you don't use") and can lead to unintended performance degradation, as in my case.
What do you think: Would it make sense to make this behavior more explicit? One idea might be to introduce a marker trait that explicitly signals that a type contains drop logic. This would allow developers to ensure that unwanted drop checks are avoided.
I look forward to hearing your thoughts!