Quick Rust Improvement Ideas — Language & Std (Community Brainstorm)

A dedicated thread for quick improvement ideas for Rust, whether it's the language itself or the standard library. No idea is too small, if you think something could be better, drop it here. After an idea is clear, feel free to create a dedicated thread for a deeper discussion. Let's keep it concise and constructive!

I create this thread because I have quite a few ideas, I think making my separate thread for each one would be hard to manage and might flood the forum. So I'm gathering them all here first. Feel free to drop your own quick ideas too!

Recently I discovered that Rust's std::String does not perform SSO (Small String Optimization). This was tested here :

That Rust's std::String falls far behind C++'s std::string because C++'s implementation does SSO. It's time for Rust's to do it too. For the implementation, does it allocate on the stack first and then move to the heap once it no longer fits?

It loses the property that moving the String doesn’t invalidate pointers to the backing data. Today, a self-referential struct like Yoke<SomeData<'static>, String> can soundly have self-references to the String in struct SomeData<'a>(&'a str). This is a stable guarantee of Rust and cannot be changed.

Plus, SSO incurs a branch every time you access the string, similarly to Cow<'a, str>; it isn’t a unilateral optimization in all cases.

Third-party crates implement various forms of String-like structures. If anyone needs the optimization, and doesn’t need String’s guarantees for unsafe code, then smol_str (as one random example) is already available. Not everything needs to be in std.

7 Likes

Rust can't do SSO without violating guarantees it has documented. Converting a String into a Vec<u8> using String::into_bytes promises not to allocate. And Vec explicitly says it never stores data inline. Vec in std::vec - Rust

Vec will never perform a “small optimization” where elements are actually stored on the stack for two reasons:

  • It would make it more difficult for unsafe code to correctly manipulate a Vec. The contents of a Vec wouldn’t have a stable address if it were only moved, and it would be more difficult to determine if a Vec had actually allocated memory.
  • It would penalize the general case, incurring an additional branch on every access.
5 Likes

important to noteCow<'a, str> doesn't currently branch on (read only) access, because the pointer and length metadata are in the same spot for both variants.

it only branches on clone/destructor/to_owned.

but this is not a stable guarantee.

i do agree with everything else that was said though

edit : i'd like to add that this optimization would not work on std's String if it was SSOed, it would definitely need the branch

8 Likes

Wow, that’s great!

2 Likes

TL;DR: a guide on available features and techniques is worth a dozen new features.

Lots of features you might wish for are already present in Rust, even if some are hard to discover.

std::thread::scope

Say,

fn handle(_a: Arc<[u8]>) {}

fn main() {
    let values = vec![3u8, 4, 5];
    let shared = Arc::from(values);
    let t1 = std::thread::spawn(|| handle(shared.clone()));
    let t2 = std::thread::spawn(|| handle(shared.clone()));
    t1.join().unwrap(); t2.join().unwrap();
}

can be rewritten using zero-cost abstraction, one which doesn't require updating any counters atomically; well, spawning a thread is always time-consuming, but regardless.

fn handle(_a: &[u8]) {}

fn main() {
    let values = vec![3u8, 4, 5];
    let shared: &[u8] = &values;
    std::thread::scope(|scope| {
        let t1 = scope.spawn(|| handle(shared));
        let t2 = scope.spawn(|| handle(shared));
    });
}

I sometimes skim through the available methods for the interest of it, which allowed me to put together how it's used, but std::thread::scope isn't explained or mentioned in its module doc at all.

Buffer reusing

fn main() {
    let mut s = String::with_capacity(9);
    for i in 300_000_000..333_000_000 {
        // i.to_string() will GlobalAlloc::allocate
        // and might even start at a smaller string then reallocate

        s.clear();
        _ = write!(&mut s, "{i}");    // free of allocations
    }
}

This one is more of a general programming technique, where if you can reuse the allocation you should. Makes code look worse. But wait, there's recently stabilized u32::format_into method which restores the ergonomics for the example! How does one learn about it?..

As for std additions: reusing Vec<Vec<T>> is rough now: you can't clear the outer vector because that'll drop and thus deallocate all rows.

1 Like

Here's something I would really genuinely like to see: allow binary crates to break the orphan rule, and more generally, allow binary crates to say "actually no, we're not going to use your trait implementation, we're going to use mine."

This might need some guard rails, but should be sound in general, because the whole reason we have to have the orphan rule in the first place is to prevent conflicts in case multiple crates try to define the same impl -- but a binary crate by definition has a global view, so it's fine. The "we use my version" mechanism addresses what happens if a dependency starts providing a trait impl in an update, and is also handy when a stock impl isn't quite right.

I posted concrete examples of where both features would be useful last year, in a thread specifically about orphan rule relaxation.

1 Like

No, it's not. If any crate anywhere in the crate graph was able to find an implementation of PartialOrd for some type T, it may have already generated some code using that particular impl. Letting the binary supply a different impl of the same type for the same trait would be unsound.

Your argument only works if we defer all codegen until the binary.

(And this isn't even getting into associated types where even delaying codegen would not be enough.)

1 Like

It's not, because I might have unsafe code that depends on my implementation of the trait.

Trivial example: if you change PartialOrd for usize, there's a ton of code that could become unsound.

5 Likes

Look on latest version of C++ you will find plenty of them.

https://crates.io/crates/match-variants

Something I was working recently, that I've realize it is std::visit in C++. Could be a feature of the language.

Another that bothering quite often, enum variant could act as type, where you can do impl for that, and everything else that you can do with types.

That's what I mean about guard rails. You should be able to mark your trait impl as not allowed to be replaced, but this should be an unusual case, on the same level of unusualness as declaring the trait itself unsafe.

So I thought we did already defer enough codegen until the binary that this would be fine (specifically, I thought .rlib held pre-monomorphization MIR) but would there be any problem in principle (that is, ignoring "how hard would this be to implement" considerations) with feeding "compilation graph root says use this" information down before we even start compiling the leaves of the dependency tree? That should make associated types work, too.

No, we definitely do not. Once all types are known we codegen all non-trivial functions. If a crate uses BTreeSet<i32> then some of the BTreeSet functions will be codegen'd then-and-there. If the crate also uses BTreeSet<i32> inside generic functions, that codegen will be deferred. Also codegen of small functions will be deferred to allow more inlining. Generally the Rust compiler will codegen such functions in some arbitrary crate and we make no guarantees about the exact choice.

MIR-only rlibs have been explored but their compile-time cost is orders of magnitude away from being acceptable. There are some crates where they are beneficial, and we have a flag to opt-in for those cases, but that's it.

The entire compilation model of Rust deeply relies on the orphan rule.

The person writing the impl and the person writing the unsafe code are not the same person. Today, unsafe code can and does rely on any impl it can see being the only impl for this trait-type pair.

Your proposal, if I understood it correctly, is I think completely impossible to ever realize in a backwards-compatible way. Even editions do not help. (This is apart form the fact that I think the resulting language would be quite hard to use correctly because of the kind of "monkey patching" that replacing impls would allow.)


I think "letting downstream overwrite upstream trait impls" is completely unworkable and also just a bad idea. The only kind of "beyond orphan rules" approach that I am aware of that has any chance of resulting in a workable programming model is to follow Scala, where multiple impls can co-exist and there are complicated rules for which code sees which impl when and where. So if you are interested in this corner of the programming language design space, I'd encourage you to take a good look at Scala.

3 Likes

It would break any existing build system that invokes rustc as build systems would have to start running rustc twice for collecting which functions to codegen and then to codegen them. This also has a perf cost, and would break badly in the face of non-reproducible proc-macros. And it also wouldn't work for rust dylibs where we are forced to eagerly codegen functions exported to C, all statics and everything reachable through them. We currently have -Zhint-mostly-unused which delays codegen to the leaf crate where possible, but that also still considers functions exported to C and statics as monomorphization collector roots and for dylibs it significantly reduces the code size benefit that dylibs give.

1 Like

For ease of integration tests I tend to do bin+lib crates, with a very thin main that just handles argument parsing in the bin side. This solution wouldn't help me thus.

2 Likes

Due to the SSO would introduce branching that prevents auto SIMD, which is also something I avoid

I have a new one. Almost all users are unknowingly running a subpar HashMap compared to HashMaps in other languages. That is, HashMap uses a heavy hashing algorithm for DDoS security. What if std::hash provided the fastest hashing algorithm available, similar to Zig's std. Then the compiler emits a written warning note: "HashMap uses SipHash algorithm by default for DDoS security which is not the fastest, if the map doesn't use input data from the external env, consider changing it to std::hash::FastHash"

Format the warning so it doesn't spam when HashMap is used many times, structured like:

Warning: message => code position 1, code position 2, etc

So it's just 1 line

This way it's visible to users and those who previously didn't know about it can now find out. This will speed up many Rust apps because HashMap is a fundamental data type and is widely used for internal data processing that doesn't receive input from the outside world. With this, Rust's HashMap still provides a safe hasher by default, but also clearly communicates its cost and tells users to switch hashers if they don't need the default hasher's security features. And with fast variants prepared in std, there's no more confusion about searching and trying them one by one, most users don't even search because they don't know any of this exists

I think this proposed warning would be way too disruptive on existing code, but I agree there's definitely room to raise awareness of the choice of hashing algorithm for people who don't read through stdlib docs pages top-to-bottom like I do.

1 Like

How about making the warning doesn't include line number, just single sentence eventhough the hashmap is called multiple times?

I sent to the wrong thread previously :rofl:

So here is

I'm wondering whether the reason Rust consumes gigabytes of SSD space for medium to large libraries, even when I've only written a small amount of my own code, is due to Rustc itself, or rather poor build artifact management and default dev config on Cargo's part? If it is in Cargo's side, could it be made that Cargo default profile is optimized for the general use case? Like no generating debug info because most users doesn't do debugging. Then the one that need debug info could use other profile specialized for debug mode

So there will 3 mode. Dev mode, debug mode, release mode

Dev mode prioritize fast compile speed and low disk usage. Debug mode prioritize debugging. Dev and debug mode should share compilation artifacts to prevent duplicated artifacts, the only difference is debug mode saves debug infos and other artifacts that are for debugging