I am currently working on a Rust to .NET compiler, rustc_codegen_clr. To get it to work, I need to implement many Rust features using .NET APIs. One of such features is panicking and unwinding.
This article is the first one in a series about Rust panics, unwinding, and my implementation of them in .NET.
In this part, I will look at unwinding (the compiler side of panicking), and in the next one, I will explain the Linux GNU std implementation of panicking.
Before I talk about the project, I should probably explain what it is. It is a "rust compiler backend" - but what does that mean?
You can imagine it as a compiler plugin, which replaces the very last step of compilation (code generation). Instead of using LLVM to generate native code, my project turns the internal Rust representation called MIR, into .NET Common Intermediate Language. CIL is then stored inside .NET assemblies, which allows the .NET runtime to load, and execute the compiled Rust code easily. 
From the perspective of the Runtime, the compiled Rust looks identical to unsafe C#. So, the Rust code can easily call .NET functions and create .NET objects. In theory, there is nothing you can do in C# that can't be done in Rust too.
I am also working on making calling Rust from C# easier. The project allows you to define .NET classes in pure Rust. In the future, the safety of your interop code will be fully checked by the Rust compiler.
// Early WIP syntax, subject to change.
dotnet_typedef! {
class MyClass inherits [Some::External::Assembly]SomeNamespace::SomeClass{
virtual fn ToString(_this:MyClass)->MString{
"I am a class defined in Rust!".into_managed()
},
}
}
The end goal is to allow near-seamless interop between Rust and C# / F#. Ideally, the users of your Rust .NET library may not even realize that it is written in Rust.
Now that I laid down some basics about how rustc_codegen_clr works, I will probably need to quickly explain what unwinding and panicking are.
While the terms panicking and unwinding are related, and sometimes used interchangeably, they are nonetheless 2 different things.
Panicking is the name for the feature of the Rust language. When you use the panic! macro, this is the feature you are using. Panicking is very similar to exceptions, and is used in Rust to signal an error in the program logic.
So, when you expect an operation could fail, you use the Result type. When an operation should not fail(a failure indicates a bug), you should use panic!s.
// Reading and parsing a file can fail, so we return a `Result` to tell the consumers of our API that they should handle it.
fn decode_file(path:&Path)->Result<DecodedFile, DecodeError>{
// ...
}
// A person's birth date can't be later than the current date.
// If it is, then either that person is a time traveler, or we have a bug.
fn calculate_age(date_of_birth:Date)->Duration{
if Date::now() < date_of_birth{
panic!("Found a time traveler!");
}
// ...
}
In general, Results are used where an error is expected, and should be handled by the user of the API, while panics tend to be used when something unexpected happens(eg. an assertion fails, an array is indexed out of bounds).
While panicking is not used as commonly as Results, it is still used in quite a few places(eg. in unit tests). So, this feature must work well.
Panicking is currently implemented using unwinding, but that does not have to be the case in the future - you can implement panicking in other ways(eg. a panic can simply terminate the program).
Those other implementations have their drawbacks, but there are (rare) situations in which using something other than unwinding makes sense.
Unwinding refers to a specific implementation of panicking, where the stack is unwound - traversed upwards, function by function, while the data on the stack is dropped.
When I refer to unwinding, I will talk about the specific implementation on the compiler level. Here, things like cleanup blocks or the exact implementation of certain intrinsic matter.
When I talk about panicking, I will refer to the implementation present inside the standard library. From the perspective of std, it just calls a few intrinsics and functions, and it does not care how things are implemented under the hood.
At the conceptual level, an unwind is relatively easy to understand.
An unwind travels up the call stack, popping stack frames of each function along the way, and dropping (disposing of) the stack-allocated objects held by that function.
So, if we had some functions like this:
fn a(){
let numbers = vec![0;100];
b();
}
fn b(){
let hello_string = "Hello World".to_string();
c();
}
fn c(){
let some_value = Box::new(64);
panic!();
}
During an unwind(caused by the panic in c), first the some_value would be dropped, then the unwind would go up the call stack, into b. The hello_string would then be dropped, unwind would continue up to a, and drop the vector numbers it allocated.
This process would continue until the unwind encounters a special intrinsic named catch_unwind. The exact signature and behavior of catch_unwind is not relevant for now, so I will discuss it in the next article.
Looking at this process, you might ask yourself a question:
How does an unwind know what and how to drop? I mean, dropping a Vec<i32> is very different from dropping a Box<i32>, so how do we know how all the objects in that stack frame need to be dropped? How can we tell where a pointer to the string hello_string is on the stack, and how can we know what to do with it?
This process of dropping (disposing of) all the data in a stack frame is the responsibility of special pieces of code called "cleanup blocks"
Before I explain what a cleanup block is, I should probably explain MIR blocks in general.
Each function in the Rust Mid-level Intermediate Representation (MIR) is made up of a series of basic blocks.
Those blocks represent the control flow of a program.
A block is made up of a bunch of statements, and a terminator.
The statements in a block are executed in sequence, and can't diverge or branch in any way. No matter what happens, they will get executed, one by one.
The terminator, on the other hand, can change the control flow of a function.
It can call other pieces of code, jump to some other block, resume an unwind, or abort the execution of a program.
While this may seem a bit odd at first, it is a very convenient representation. It makes analyzing control flow almost trivial - since we know that only terminators can change how the code is executed, we don't have to check each statement to figure out the relationships between blocks.
Another nice side-effect of this distinction is that it simplifies unwinding and cleaning up stack frames.
Since only a terminator can call other functions, only terminators can panic. Executing a statement will never panic, since statements can't change the control flow of a program.
So, when we want to specify how to deal with a stack frame during an unwind, we only need to do so for each terminator.
Now that I explained what a basic block is in the context of MIR, I can show exactly what a cleanup block is.
As I mentioned before, a cleanup block is not called during “normal” execution. The sole purpose of a cleanup block is dropping (disposing of) all the things in a given stack frame. So, during an unwind, when we want to figure out how to drop a particular frame, we simply call the specified cleanup block.
The Rust compiler generates such a cleanup block every time it needs something to get dropped during an unwind.
This concept can be a bit hard to grasp, but a small example should make it easier to follow. I will demonstrate how a cleanup block works, by using some Rust-inspired pseudocode.
Let's say we have a program like this:
fn test(x:u32){
b0:{
let x = 7;
// If an unwind happens during this function call, `numbers` will not be yet owned by us.
// Since we only have ownership of `numbers` once the we allocate the vector(using this function),
// dropping it during an unwind is the responsibility of the callee.
// So, we don't need a cleanup block.
let numbers = vec![x] -> [return bn1, unwind continue];
}
b1:{
// Up till this point, there was nothing to drop. Now, after the variable `numbers` was allocated, there is something to drop,
// so we specify that the cleanup blocks b4 need to be called if an unwind happens.
x = 8;
do_something(&numbers) ->[return bn2; unwind bn4];
}
b2:{
x = 9;
// Since calling pass_ownership requires passing the ownership of `numbers`, dropping it is the responsibility of the callee.
// So, we don't specify any cleanup blocks.
pass_ownership(numbers) -> [return bn3, unwind continue];
}
b3:{
return;
}
// The cleanup block generated by the compiler
b4 (cleanup):{
// If this block is called, then we need to dispose of `numbers`. So, we drop it.
drop(numbers);
unwind resume; // Continues the unwind
}
}
The real MIR looks a bit different(no variable names, explicit types of locals, calls that return nothing assign a () value), but this should give you a rough idea about how everything works.
As you can see here, cleanup blocks are not really all that scary. Despite the fancy name, they are eerily similar to finally or using in C#.
// If an exception occurs, numbers.Dispose() will be called.
using(Vec<int> numbers = new Vec{x}){
do_something(ref numbers);
}
The main difference between using/finaly and a cleanup block is that it is only called when an unwind happens, while using disposes of the resource when the control flow leaves it. So, it will get called when an exception occurs, but also just during normal execution.
Still, despite those differences, unwinds and exceptions map to each other nicely, and exception handlers can be used to implement cleanup blocks.
There are, of course, some differences between the two which make this not a 1 to 1 translation.
MIR cleanup blocks can jump to other cleanup blocks, and a .NET execution handler can’t jump into another exception handler.
Working around that required some tinkering, but it was not a big problem.
Now that I talked a little bit about cleanup blocks in general, I thought I might mention another oddity of unwinds in Rust.
Drop flags are variables, whose sole purpose is to tell a cleanup block if it should drop something or not.
Hang on a minute, did I not just tell you that we only need at most one cleanup block for each terminator, since only terminators can cause an unwind?
If we already have separate cleanup blocks for different terminators, why do we need additional flags to tell the cleanup block what to drop?
Suppose we have some code like this:
// Example taken from the Rustonomicon
// https://doc.rust-lang.org/nomicon/drop-flags.html
let x;
if condition {
x = Box::new(0); // x was uninit; just overwrite.
println!("{}", x);
}
// x goes out of scope; x might be uninit;
// check the flag!
As you can see, x may or may not be initialized, depending on the condition.
If x is not initialized, then dropping it is UB. If it is initialized, then not dropping it would be a mistake.
To solve this, the compiler introduces a second variable, which checks if x is initialized.
let x;
// Hidden, compiler-generated drop flags
let _is_x_init = false;
if condition{
x = Box::new(0);
_is_x_init = true;
println!("{}", x);
}
if _is_x_init{
drop(x);
}
You may say: well, that example is very contrived, and no one writes code like this! You just have to move the definition of x within the body of the if statement, and there would be no problem!
if condition{
let x = Box::new(0);
println!("{}", x);
// x is always initialized here, so we can drop it.
}
Yeah, I would be shocked if someone wrote code exactly like this anywhere. But, this is the simplest possible example of the need for drop flags.
More realistic examples are far more complex, and this is the easiest way to showcase why drop flags are needed.
Now that I explained how rust cleanup block works, and how they get turned into exception handlers, I want to highlight a few problems I encountered while optimizing them.
Currently, the Rust code compiled for .NET suffers from massive slowdowns in a few key areas.
While I don't want to discuss the exact numbers since I am not 100% confident in them, I can at least highlight some general trends.
Rust compiled for .NET is, as expected, always slower than native Rust. However, it is not that much slower. In quite a few benchmarks, it is within 1.5 - 2x of native code - which I feel like is a pretty acceptable result. In the majority of cases, it is no more than 5x slower than native code. This is not great, but it is also not a bad starting point, seeing as my optimizations are ridiculously simple, and not all that effective.
Being wrong fast is not impressive, so I am mostly focusing on compilation accuracy and passing tests right now.
However, a select few benchmarks jump out. Some of them are pretty much expected (eg. a benchmark showing the vectorization capabilities of LLVM), but other ones may seem a bit more surprising at first.
One particularly problematic area is complex iterators, some of which take 70x more time to execute in NET.
That is, of course, unacceptable. You may ask yourself: what causes this slowdown?
Even though those benchmarks never panic, the cost of the unwinding infrastructure is still there. Just disabling unwind support(which just removes all the exception handlers) speeds up this problematic benchmark by 2x.
Of course, being 35x slower than native is still nothing to brag about, but it at least guides us toward the underlying problems.
Let us look at one of the problematic functions, decompiled into C# for readability.
// NOTE: for the sake of this example, the custom optimizations performed by `rustc_codegen_clr` were disabled.
public unsafe static void _ZN4core4iter8adapters3map8map_fold28_$u7b$$u7b$closure$u7d$$u7d$17hce71e23625930189E(Closure2n22Closure1n11Closure1pi8v * P_0, RustVoid acc, long elt) {
//Discarded unreachable code: IL_002b, IL_006f
Closure1n11Closure1pi8 * f_;
bool flag;
long item;
try {
f_ = & P_0 - > f_0;
flag = true;
void * ptr = (void * ) 8 u;
Tuple2i8 tuple2i;
tuple2i.Item1 = elt;
tuple2i = tuple2i;
item = _ZN4core3ops8function5FnMut8call_mut17hd1566fb973e9735aE(ptr, tuple2i.Item1);
} catch {
if (flag) {}
throw;
}
try {
flag = false;
Tuple3vi8 tuple3vi;
tuple3vi.Item2 = item;
tuple3vi = tuple3vi;
RustVoid rustVoid;
_ZN4iter13for_each_fold28_$u7b$$u7b$closure$u7d$$u7d$17hfc540be8426f74d2E(f_, rustVoid, tuple3vi.Item2);
} catch {
if (flag) {}
throw;
}
}
You may immediately notice something very odd. All the exception handlers in this function are effectively nops!
In some other examples, those catch blocks do assign some values to local variables, but, as soon as the handler finishes execution, those locals will be discarded anyway - since the exception will be rethrown(an unwind will resume).
catch {
nint num6 = (nint) self.b.v;
if (num6 != 1 || flag) {}
throw;
}
And what about those useless ifs? They are empty, so they can't do anything at all!
Those ifs, weird assignments, and empty handlers are ghosts of removed drops.
The example I showed you is nothing more than a faithful recreation of the original optimized MIR passed to me by the Rust compiler front end. All those useless blocks and instructions are there because the compiler explicitly asked me for them.
You may think: why would rustc explicitly request a useless block? Can’t it see that it does nothing?
I mean, the sole reason for MIRs existence is optimization. If this is its only job, surely it would be better at it?
Well, what if I told you that the purpose of MIR optimizations is not to make your code faster, but to make compilation faster?
How on earth does an extra step in compilation make it faster?
Well, as you may know, LLVM is not exactly the fastest thing in the world. It is very good at producing fast code, but it takes its sweet time to do it.
Let us say we know that a certain kind of LLVM optimization is quite slow, but we found a faster way to achieve the same thing, by using the unique properties of Rust.
Well, if rustc did just that, then LLVM wouldn't need to perform its own costly optimization, and compilation time would improve.
This kind of scenario is not very common. We can, however, exploit a different property of Rust to make a lot of optimizations way faster.
The nice thing about generics is that they allow for a lot of code to be shared. What if, instead of optimizing the “final” monomorphized versions of a function(like what LLVM does), we could optimize the generic one?
Look at this function:
fn do_sth<T:Copy>(val:&T){
let tmp = *val;
do_sth_inner(&tmp, 8);
}
For each different type, T LLVM will receive a different version of this function from rustc Each of those variants needs to be optimized separately. Let us imagine we call this function with 1000 different Ts. This means LLVM will have to optimize 1000 different versions of this function. This will take time.
However, we could try optimizing the generic version of this function. We might not be able to perform all the optimizations, but we should be able to perform quite a few of them. In this example, we could avoid storing the value of val in tmp. Since we only use the address of tmp to pass it to another function, val is an immutable reference to T, and tmp is a bitwise copy of what val points to, this optimization is OK.
fn do_sth<T:Copy>(val:&T){
do_sth_inner(val, 8);
}
In one move, we optimized all the possible variants of do_sth. LLVM would have to do the same thing each time do_sth is used. So, for any function that is used more than once, we should save some compilation time.
This is the main reason MIR exists. If we can optimize stuff before handing it to LLVM, we can save time. Disabling MIR optimizations would not slow the final native code all that much, but it would make LLVM have to do more work - which would increase compile times.
So, we now know that MIR optimization operates on generic functions and that they can leverage Rust-specific information(eg. borrows) to perform certain optimizations faster.
However, there are some problems with this approach.
rustc can't perform certain kinds of MIR optimizations, since they might not be valid for all possible Ts. For example, if the type in our example was Clone, and not Copy, we would not be able to optimize it all that much.
fn do_sth<T:Clone>(val:&T){
let tmp = val.clone();
do_sth_inner(&tmp, 8);
}
The clone could have side effects(eg. incrementing an atomic reference counter), and dropping tmp could also do something(eg. decrementing an atomic reference counter).
This is not a problem in most cases - since LLVM operates on monomorphized functions(functions with T replaced by some type, like i32), it can still perform this optimization, where applicable.
You can now probably guess why the iterator code I showcased had empty exception handlers(and cleanup blocks) - those blocks are not always empty.
When you look at the original example, you might notice it operates on longs(aka i64s). Dropping an i64 is a NOP.
During compilation, when the cleanup block in question is processed, my backend encounters a drop. When it encounters a drop, I use Instance::resolve_drop_in_place to get information about how to drop a variable.
let drop_instance = Instance::resolve_drop_in_place(ctx.tcx(), ty).polymorphize(ctx.tcx());
if let InstanceKind::DropGlue(_, None) = drop_instance.def {
//Empty drop, nothing needs to happen.
vec![CILRoot::GoTo { target: target.as_u32(), sub_target: 0}.into()]
} else {
// Handle the drop
}
One of the possible ways to drop something is calling the "DropGlue". Drop glue is basically a function that calls the drop implementation. However, when something (like an i64) does not need dropping, the drop glue can be None. If it is None, then there is nothing to do during a drop. In such a case, I do nothing(besides jumping to the next block).
So, this "empty" drop compiles into nothing - that explains our empty exception handlers. In reality, those handlers look more like this:
catch {
nint num6 = (nint) self.b.v;
if (num6 != 1 || flag) {
// Ghost drop
drop_i64(&item); // Drops the i64 - does nothing.
}
throw;
}
But the drop_i64 is never generated, since it is not needed.
Optimizing exception handlers is very important for Rust to run well inside the .NET runtime. The biggest problem with them is the amount of byte code those things create. Remember - each Rust block can have its own cleanup bloc, and jumping between exception handlers is not allowed in .NET. This means that I have to store multiple copies of certain blocks.
This huge amount of exception handlers is not liked by the .NET JIT. I will admit that I am not an expert when it comes to understanding how RyuJIT works - I only have a very rough idea about how all the pieces fit together. Still, I feel like I know enough to speculate about the cause of the slowdowns I observed.
rustc_codegen_clr is not yet all that good at optimizing code, so it generates a lot of CIL. Each unneeded exception handler, unused variable - all that adds up.
While the .NET JIT is pretty good, and can optimize most of the inefficiencies away, the increased bytecode size still has an impact. Internally, most JIT's (including RyuJIT) use certain heuristics to estimate if certain optimizations are worth it. So, a JIT may use metrics like the number of exception handlers, locals, and the size of bytecode, to try to guess how large the final compiled function is.
Since I emit a lot of code (quite a bit of it useless), the JIT "thinks" my functions are bad optimization and inlining candidates.
The exact formula used to calculate the cost of operations like inlining is not all that relevant to this post (you can find it here), but I still wanted to highlight some things that seem to explain what I observed.
Looking at the default inlining policy, it seems like RyuJIT will not inline a function, if it exceeds a certain limit of basic blocks.
// Code snippet from: https://github.com/dotnet/runtime/blob/02e95ebff119189ac2d65e4a641980ccb3ea1091/src/coreclr/jit/inlinepolicy.cpp#L577
// CALLEE_IS_FORCE_INLINE overrides CALLEE_DOES_NOT_RETURN
if (!m_IsForceInline && m_IsNoReturn && (basicBlockCount == 1))
{
SetNever(InlineObservation::CALLEE_DOES_NOT_RETURN);
}
else if (!m_IsForceInline && (basicBlockCount > MAX_BASIC_BLOCKS))
{
SetNever(InlineObservation::CALLEE_TOO_MANY_BASIC_BLOCKS);
}
This limit seems to currently be 5.
Since I need to duplicate all used cleanup blocks for each handler (jumping between exception handlers is not allowed), most handlers contain multiple blocks.
Let us say we have 2 handlers, with 2 blocks each. That alone already adds up almost to the inline limit.
When I removed all the exception handlers from an assembly, I removed a lot of those blocks, allowing the JIT to inline more functions.
Overall, removing this dead code, and reducing complexity, encourages the JIT to perform more optimizations.
You may wonder: why do JIT's use heuristics like this, and why do they only perform optimizations in certain cases?
JIT's try to be fast at compiling code. You don't want to have to wait a couple of minutes for your .NET app to start. So, a JIT has a limited time budget. It has to weigh the benefit of any give optimization against the time it would take. You don't want the JIT to spend a lot of time optimizing a complex function, if it can spend the same time optimizing 100 simpler ones.
In general, JIT's focus on code that is used often, and does not take a lot of time to optimize. This way, they can give you some pretty decent performance, while also compiling CIL very quickly.
If the CIL I generate is simpler, then the JIT can "see" that optimizing it will not take too long. By better preparing a .NET assembly for JIT compilation, I can reduce the amount of needless work a JIT needs to do, allowing it to perform other, highly beneficial optimizations.
Of course, you should take this paragraph with a pinch of salt. While I am pretty confident in what I wrote, I am still an amateur when it comes to JIT's. So, I might have misunderstood something or missed some finer detail.
Now that I explained why those optimizations are needed, I will show how I implemented them.
To remove a useless cleanup block(or exception handlers) I first need a good way of detecting them. Originally, I went with a more complex solution: First, I would simplify each block separately, removing all the side-effect-free statements before a rethrow.
// Since assigning a constant to a local has no other side effects, and this bit of CIL is followed by a rethrow instruction, we are free to remove it.
ldc.i4.0
stloc.0
rethrow
// Simpler, equivalent version
rethrow
Next, I would replace unconditional jumps to lone rethrow ops with rethrows.
br bb1
bb1:
rethrow
// Simpler, equivalent version
rethrow
After a few more steps dealing with conditionals, most handlers would get simplified away into a single rethrow instruction.
catch [System.Runtime]System.Object{
pop // Ignore the exception Object
rethrow
}
As a last step, I would simply remove those trialy-NOP handlers.
This approach had a lot of advantages - in cases where it was not able to fully optimize something away, it would still simplify it considerably.
However, the added complexity made this optimization a bit error-prone. For the sake of simplicity, I have overlooked some of the non-obvious issues. Most of them were caused by some of the tech debt the project has. Fixing them requires some additional refactors, which are just not done yet.
Since the code in cleanup blocks only runs during an unwind(so not during normal execution), debugging it is a bit of a pain.
Any issues in cleanup blocks will also be very hard to notice:removing a necessary cleanup block can just lead to a memory leak. So I need to get all the optimizations operating on them to be perfect.
In order to avoid those issues, I have decided to go with a much simpler, more conservative approach for now.
It is not as good at optimizing stuff away, but it can still improve things a fair bit.
I simply go through all the CIL in the handler, checking if it can cause observable side effects.
For example, assigning a const value to local has no side effects, while setting a value at some address has side effects.
If a handler only consists of assignments to locals, jumps, and a rethrow, then it can't have any side effects, so we can just remove it.
After this optimization(and other CIL optimizations implemented by rustc_codegen_clr), the iterator function I showed before looks like this:
public unsafe static void _ZN4core4iter8adapters3map8map_fold28_$u7b$$u7b$closure$u7d$$u7d$17hce71e23625930189E(Closure2n22Closure1n11Closure1pi8v * P_0, RustVoid acc, long elt) {
//Discarded unreachable code: IL_0025, IL_0047
Closure1n11Closure1pi8 * f_ = & P_0 - > f_0;
void* ptr = (void*) 8u;
Tuple2i8 tuple2i;
tuple2i.Item1 = elt;
Tuple3vi8 tuple3vi;
long num = (tuple3vi.Item2 = _ZN4core3ops8function5FnMut8call_mut17hd1566fb973e9735aE(ptr, tuple2i.Item1));
RustVoid rustVoid;
_ZN4iter13for_each_fold28_$u7b$$u7b$closure$u7d$$u7d$17hfc540be8426f74d2E(f_, rustVoid, tuple3vi.Item2);
}
Don't get me wrong - there are still a lot of optimizations to be done(the whole function can be optimized to one line, and easily inlined), but this is far better than what we had before.
I hope you liked this deep dive into Rust unwinding, drop flags, and MIR optimizations. Originally, this was just supposed to be a "short introduction" to unwinding, after which I would talk about how panics work in std.
Well, it turns out unwinds are a bit complex. Since this article is already one of my longest, I will have to split it into 2-3 parts.
I also plan to explain the difficulties with Rust code catching arbitrary .NET exceptions and talk a little bit more about safe Rust / .NET interop.
So, look forward to that :).
thank yous.Oh, I almost forgot! `rustc_codegen_clr` turned 1 year old on August 28th! I would like to thank You for the amazing reception it received (over 1.4 K stars on Github, and a lot of very nice comments).
I(and my project) also participated in Rust GSoC 2024. I originally planned to write an article summarizing GSoC and celebrating the project's birthday, but I was unable to do so due to health reasons. I want to thank my GSoC mentor, Jack Huey, for mentoring me and helping me with some of the organizational stuff. Helping me to stay on track was really important for me since I work better when I have a sense of direction. This may seem small, but receiving even a tiny bit of feedback helps a lot.
I also wanted to thank Jakub Beránek(one of the Rust people behind Rust GSoC 2024) for his work organizing and managing Rust GSoC. Good management is almost invisible: everything just goes according to plan. Still, it is something to appreciate.
I am, in general, a bit of a messy and forgetful person, so I really appreciate that everything was going smoothly.
Since I am talking about the organizational stuff, it would be a crime not to thank the Google GSoC team for making GSoC in the first place. To be quite honest, I assumed that the team behind GSoC had to be pretty sizable since everything worked like a well-oiled machine. Learning how small it is(2.5 people) was a big shock to me, and made me appreciate their work even more.
I also want to express my gratitude towards the wider members of the Rust project, who helped me tremendously. It would be hard to list everyone who answered my question by name, but I would like to explicitly mention Bjorn3, who answered quite a lot of them.
One of the greatest things about GSoC was receiving feedback and help from a lot of different people.
Overall, GSoC has been a blast, and I met a lot of truly amazing people there. Each project felt like something impactful, and all my interactions with other Rust GSoC contributors were amazing.
Meeting all of those people passionate about Rust gave me a lot of confidence in the future of the language.