Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shall we require no_std compatibility? #77

Closed
sffc opened this issue May 7, 2020 · 19 comments · Fixed by #154
Closed

Shall we require no_std compatibility? #77

sffc opened this issue May 7, 2020 · 19 comments · Fixed by #154
Assignees
Labels
A-design Area: Architecture or design C-meta Component: Relating to ICU4X as a whole T-docs-tests Type: Code change outside core library
Milestone

Comments

@sffc
Copy link
Member

sffc commented May 7, 2020

Being compatible with #![no_std] is important for running on low-resource devices. The benefits of no_std include:

  1. Makes it harder to accidentally pull in large standard library dependencies, reducing code size
  2. Adds the ability to remove expensive debugging machinery (in terms of code size) from the standard library in release builds, such as stack traces and pretty-print error messages
  3. Compiler settings like -Z can be used to compile standard library code

In a no_std environment, we would still depend on the alloc crate. A lightweight allocator such as wee_alloc can be used when necessary.

@sffc sffc added T-docs-tests Type: Code change outside core library C-meta Component: Relating to ICU4X as a whole A-design Area: Architecture or design labels May 7, 2020
@Manishearth
Copy link
Member

Compiler settings like -Z can be used to compile standard library code

This can be done with xargo

Adds the ability to remove expensive debugging machinery (in terms of code size) from the standard library in release builds, such as stack traces and pretty-print error messages

This can be done with panic=abort

@sffc sffc self-assigned this May 7, 2020
@kpozin
Copy link
Contributor

kpozin commented May 13, 2020

If we're going to require no_std support, we should probably standardize on a list of no_std crates to replace common functionality in the standard library. For example, what should we use instead of std::collections::HashMap?

@sffc
Copy link
Member Author

sffc commented May 16, 2020

I took another look at this in light of @Manishearth's comments.

Advantage 1: Encourage Best Practices

I feel that #![no_std] encourages best practices that are consistent with the goals of our project.

A stated goal of ICU4X is small code size and constrained resources; #![no_std] has the same goal.

The pieces of the standard library that do exist in core and alloc are the most fundamental and lightweight. With #![no_std], you get a hard error if you pull in things we don't want, like RegEx, from the standard library. You can't use std::io without putting it behind a feature flag. I think that's good.

Advantage 2: Reduce Debugging Machinery

In terms of advantage number 2: panic=abort certainly goes a long way here. There does appear to be at least one more step though that you can take in #![no_std] to further remove debug machinery, which is to set the (unstable) #[alloc_error_handler]. The cost savings are not huge, but I was able to cut out 15% of my binary size by using #![no_std] + #[alloc_error_handler]. I compiled the following function:

extern "C" {
	#[allow(improper_ctypes)]
	fn alert(s: &str);
}

#[no_mangle]
pub fn greet(input: &str) {
	let mut message = String::new();
	message.push_str("Hello, ");
	message.push_str(input);
	message.push_str("!");
	unsafe {
		alert(&message);
	}
}

See my results: no_std versus std.

Advantage 3: Recompile Standard Libary

I was under the impression that #![no_std] implies that the core and alloc crates are compiled on the fly with custom compiler settings. It appears I was mistaken on that point, and you do still need to invoke xargo for that functionality.

There's one exception here. If you want to use std::collections::HashMap, in a #![no_std] environment, you can depend on the equivalent hashbrown crate, which is no longer part of the standard library, so I think it should get recompiled with your custom compiler settings.


Let me also go over some disadvantages to requiring #![no_std].

Disadvantage 1: Harder to Code

@hsivonen brought up the point that requiring #![no_std] means that you need to use different imports, different coding style, etc.

I brought up the point that the no_std_compat crate makes it such that you can depend on the same old standard library features with the same syntax, via extern crate no_std_compat as std; at the top of your lib.rs.

Regarding coding style: this is by design, and is Advantage 1. Rust makes it really easy to go overboard with the standard library, and we want to discourage that practice in ICU4X.

However, my opinion on this issue could be shaped by further experience of things that you can no longer do without std.

Disadvantage 2: Easy to Add Later

If we restrain ourselves from not using standard features like RegEx and std::io, then yes, adding #![no_std] support later is easy. However, the best way to restrain ourselves in that way is to add #![no_std] support from day 1.

Disadvantage 3: Best would be to avoid the alloc crate

My proposal for #![no_std] is to by default use both the core and the alloc crates. alloc includes things like String, Vec, and so forth. @Manishearth pointed out that #![no_std] crates generally tend to avoid using alloc.

This is a valid concern, but it falls in the same bucket as Disadvantage 2. If someone comes to us and wants to eliminate the alloc requirement. By enabling #![no_std], we will have already done half the work; in a second sweep, we can just disable certain APIs based on the alloc feature.


In conclusion: clearly I am in favor of requiring #![no_std]. Some of this could be my personal bias. I have attempted to lay out what I see as the advantages and disadvantages in this post.

What do people thing?

@Manishearth
Copy link
Member

With #![no_std], you get a hard error if you pull in things we don't want, like RegEx, from the standard library.

We don't have regex in the stdlib.

The Rust stdlib is very small, it basically has:

  • IO/filesystem primitives
  • collections
  • concurrency primitives (threads, mutexes, etc)
  • various common traits
  • libcore reexports

The traits are free. We want to be able to use the collections. I don't see us accidentally using IO/fs primitives for the kind of stuff we're doing. Someone might pull in concurrency stuff but that should be easily caught in review, and I can imagine us wanting to use things like rayon as an optional speed up feature.

Most of Rust's "footprint" ends up being in the other libraries you pull in, and we can try and keep a tight grip on that via CI checks that require whitelisting dependencies. Worth noting, #[no_std] will not stop dependencies from pulling in std, though you can use cargo-no-std-check for that.

I do not think no_std is effective as a tool for keeping a low footprint, and I haven't heard of people in the Rust community doing this before.

My proposal for #![no_std] is to by default use both the core and the alloc crates. alloc includes things like String, Vec, and so forth. @Manishearth pointed out that #![no_std] crates generally tend to avoid using alloc.

My point is more that no_std + alloc is not very useful for users, because users are either 100% no_std or 100% std, no_std + alloc is a pretty niche use case and from a user POV there's not much benefit of us doing that.

@zbraniecki
Copy link
Member

I have much less experience in this domain but I also feel like we should rather identify a subset of the project that we want to set no_std for (and no alloc) and I don't think no_std is needed to keep the low-overhead hygine for us.
My preference would be to try to add a CI action similar to perf and code coverage that shows size/mem impact of a PR.

@sffc
Copy link
Member Author

sffc commented May 16, 2020

We don't have regex in the stdlib. The Rust stdlib is very small

Hmm, I was under the false impression that we did. @nciric

I do not think no_std is effective as a tool for keeping a low footprint, and I haven't heard of people in the Rust community doing this before.

Noted. I count this as a strike against Advantage 1.

My preference would be to try to add a CI action similar to perf and code coverage that shows size/mem impact of a PR.

This is a good idea, and is something we should do regardless of whether we are no_std or not.


To recap the advantages of no_std from my point of view:

  1. Best practices, although Manish says no_std is not an effective tool for that.
  2. Reduced debug machinery, which is nonzero, but less than I had originally thought.
  3. Recompile standard library, which really only affects hashbrown.

Manish has poked holes in most of my advantages arguments (thanks for keeping me honest). On the other hand, I haven't really seen a disadvantage that is compelling.

There is also one more item I wanted to add as an Advantage.

Advantage 4: WebAssembly Ecosystem

I think this is how the idea of no_std first got in my head. I was reading various blog posts recommending no_std as a good option for WebAssembly. Here are some sources:

Rust-WASM Book

https://rustwasm.github.io/book/reference/which-crates-work-with-wasm.html

Crates that do not rely on the standard library tend to work well with WebAssembly.

WeeAlloc

https://github.com/rustwasm/wee_alloc

wee_alloc: The Wasm-Enabled, Elfin Allocator. Designed for the wasm32-unknown-unknown target and #![no_std].

https://docs.rs/wee_alloc/0.4.2/wee_alloc/#using-wee_alloc-as-the-global-allocator

To get the smallest .wasm sizes, you want to use #![no_std] with a custom panicking hook that avoids using any of the core::fmt infrastructure.

Reddit

https://www.reddit.com/r/rust/comments/dp1omc/rust_2020_exploit_dominance_in_web_assembly_and/

Rust 2020: exploit dominance in web assembly and no_std ... no_std, async, and alloc have added a lot of fascinating new capabilities to the wasm ecosystem

@sffc
Copy link
Member Author

sffc commented May 16, 2020

@fitzgen Do you consider no_std+alloc a best practice for writing a WebAssembly-compatible Rust library?

@Manishearth
Copy link
Member

Manishearth commented May 16, 2020

The disadvantage from my POV is mostly that it's annoying to work with, especially since we need to pull collections back in as crates, losing interop if we ever need to e.g. return a hashmap.

It's also just ... weird, it's off the beaten path which means there will be a lot of crates we may want to use that could theoretically work with no_std+alloc but do not do that because no_std+alloc is a rare use case.

You're right about the wasm thing, but also that's mostly a quick rule of thumb: if we avoid fs/io APIs and threading we're fine. And we won't need fs/io except in some data providers, but we can write wasm-specific web-sys data providers if we need. Threading would also likely be optional perf features if we use it at all. So I just don't see us accidentally using features that make wasm hard. I would absolutely defer to fitzgen on the wasm specifics though.

@sffc
Copy link
Member Author

sffc commented May 16, 2020

losing interop if we ever need to e.g. return a hashmap

This is solvable. When the std feature is enabled (the default), we return a standard HashMap. When that feature is not provided, we return a custom hash map type. I think this happens transparently with the no_std_compat crate.

it's off the beaten path which means there will be a lot of crates we may want to use that could theoretically work with no_std+alloc but do not do that because no_std+alloc is a rare use case

Don't know about the full ecosystem, but Serde supports no_std+alloc (which I use in #61). Hopefully our dependencies are relatively minimal, so if we encounter any such crates, we can add no_std+alloc support upstream.

@fitzgen
Copy link

fitzgen commented May 16, 2020

@fitzgen Do you consider no_std+alloc a best practice for writing a WebAssembly-compatible Rust library?

I do not, except in the relatively rare case where library always makes sense as a no-std+alloc library (e.g. bumpalo). In practice, all you're doing by using no-std+alloc is making development harder for yourself, and using dependencies will get even harder.

For example, if a toml crate has a method to parse toml from a file and another method to parse from a slice, it probably uses std and doesn't feature gate the file-using method behind a "std" cargo feature. So if you just want to use the parse-from-slice method from wasm, you don't technically need std, just allocation, but let's consider how the scenario plays out:

  • If your crate uses std, then everything will Just Work as long as you don't call the parse-from-file method.

  • If your crate doesn't use std, then you have to propose changes upstream to add cargo features and all that. Its a hassle, and then after that, all it gets you is a good feeling about ideological purity. Things aren't any better than if you had just used std the whole time.

@sffc
Copy link
Member Author

sffc commented May 17, 2020

Thank you for your time and reply.

My responses are below. I really don't mean to sound stubborn, and I apologize if I'm coming across that way. I know I do not have the same level of experience in this subject as the others on this thread. However, I feel that some of my points in favor of no_std have not been adequately addressed, and I am not convinced by the arguments against no_std.


... using dependencies will get even harder. For example, if a toml crate has a method to parse toml from a file and another method to parse from a slice, it probably uses std and doesn't feature gate the file-using method behind a "std" cargo feature.

But, this is exactly what serde-json is doing. It feature-gates from_reader (which uses std::io) on the std feature.

https://github.com/serde-rs/json/blob/d13374812226f20c9d194bcf5aa1bcb36f759bd6/src/de.rs#L2309

That's the practice I think would be good to adopt in ICU4X; it forces you to think about what features are useful for native Rust clients, and what subset to carve out for environments where I/O or threading is unavailable. That's a good thing, not a bad thing.

Furthermore, ICU4X is low on the stack. We don't want a lot of dependencies. If we do want to add a convenience feature that pulls in a dependency, we probably want to feature-flag it, in which case it is easy to make it std-only.

In fact, I see the dependencies argument as one in favor of us adopting no_std. The no_std movement in Rust appears to have a fair bit of steam. We're building a low-level library intended to be used by hundreds of clients. The chances of someone coming to us wanting to use ICU4X in no_std (with or without alloc) seem rather high. By not building with no_std as a target audience, wouldn't we be contributing to the problem of Rust ecosystem libraries not being no_std-ready?

If we were building a leaf application where we don't expect many dependents, no_std may be overkill; but, when we're talking about a low-level library, I think the conversation is different.

all you're doing by using no-std+alloc is making development harder for yourself

I've written a fair bit of code already as no_std, and I did not find my development velocity to be significantly hindered. The no_std ecosystem, especially with the help of crates like no_std_compat, makes no_std development basically the same as std development, except with "as designed" constraints such as requiring that std::io goes behind a feature flag.

@nciric
Copy link
Contributor

nciric commented May 17, 2020 via email

@Manishearth
Copy link
Member

Manishearth commented May 18, 2020

But, this is exactly what serde-json is doing. It feature-gates from_reader (which uses std::io) on the std feature.

Serde is super low level on the stack, it's a foundational crate in rust and as such it can expect to be used everywhere.

The no_std movement in Rust appears to have a fair bit of steam.

This is the no_std movement without alloc. I'm very much in favor of our crates being no_std-compatible if their core functionality does not require allocation (with feature flags enabling any allocation-requiring extra goodies if we need).

I'm only arguing against supporting the no_std+alloc case. It's a weird edge case and will be more trouble than it's worth.

Overall it seems to me that your arguments do support choosing to try for no_std pretty well, however there's not much that convinces me about no_std + alloc. This might be the core issue here that makes it feel like we're talking past each other a bit.

Concretely, my proposal is:

  • if a crate's core functionality does not require std, we should absolutely write it to be no_std compat (with a default-on std feature that enables extra goodies, the usual way to do no_std support)
  • if a crate's core functionality requires allocation, we should just make it require the stdlib and not bother with std+alloc.

I find drawing this line to be far more useful than just doing no_std + alloc because nearly all no_std users cannot use alloc. We could also have a three-tiered system where we support no_std, no_std + alloc, and std modes, but this gets gnarly and complicated pretty quickly.

@sffc
Copy link
Member Author

sffc commented May 22, 2020

Thanks for the post about the three-tiered system. That makes a lot of sense and is a good way of looking at this problem.

Regarding Allocation and the non-alloc no_std tier

My experience is that much of ICU's functionality can be written using "stack only" data structures, without requiring a memory allocator. However, there's always going to be some locale that has a 100-byte month name, exceeding the limit of the stack structure. In ICU, we usually fall back to a heap allocation in this case. What do you suggest as a best practice to handle such situations?

  1. Feature-gate: return an error in no_std, and allocate when std (or alloc) is available.
  2. Implement some fallback behavior, like truncating strings if they are too long.
  3. Don't support such features in no_std; simply disable them.

Do you further suggest that we try to use the heapless crate? Is there a way to use heapless in no_std but std or alloc if those features are available?

Regarding the no_std+alloc tier

Here's how I see the use cases for the three tiers:

  1. no_std = useful for embedded devices with no memory allocator.
  2. no_std+alloc = useful for WebAssembly, FFI, and environments where I/O is unavailable.
  3. std = useful for Rust-native clients.

Manish is advocating for combining tiers 2 and 3, and Fitzgen said the same. I'll go ahead and rehash my concrete reasons why I think keeping tiers 2 and 3 separate might be an advantageous solution:

  • Encourages best practices. I acknowledge that this isn't the only way to encourage best practices, but it seems like an easy and useful one.
  • Somewhat smaller binary sizes. The lack of std hash maps is a forcing function to (a) use lighterweight solutions and (b) have the hashbrown module compiled as a dependency. I also raised #![alloc_error_handler] as what I believe to be another way we reduce binary size relative to the std tier.
  • It's a slightly safer version of pure no_std, since we have a well-defined fallback behavior (see my discussion about fallback behavior in the previous section).
  • Code-wise, we get this tier basically for free by using no_std_compat, as explained earlier.

On the second bullet about smaller binary sizes, I know you've suggested xargo. My experience with xargo has been very mixed, and I don't think it's a good idea to make our toolchain require it. no_std+alloc is, in my opinion, a more elegant solution than xargo.

About dependencies not being compatible with no_std+alloc, so far we don't have any actual examples of such dependencies. If we start with no_std+alloc and find that to actually be a problem, we can always change later. It's easier to change from "more strict" to a "less strict" than the other way around.

@Manishearth
Copy link
Member

In ICU, we usually fall back to a heap allocation in this case. What do you suggest as a best practice to handle such situations?

I think having no_std be able to fall back to a panicky variant seems okay. Ideally one that returns errors, but well documented panics work too.

We could try doing this via a shim crate. It's also possible to start off first with the "no_std users don't get this" and gradually make things compatible.

Encourages best practices. I acknowledge that this isn't the only way to encourage best practices, but it seems like an easy and useful one.

I don't think it really succeeds in this. It doesn't do this for dependencies, and it enforces a very rough best practice instead of the finer-grained stuff we probably need.

Somewhat smaller binary sizes. The lack of std hash maps is a forcing function to (a) use lighterweight solutions and (b) have the hashbrown module compiled as a dependency. I also raised #![alloc_error_handler] as what I believe to be another way we reduce binary size relative to the std tier.

Rust will not include things we don't need, this doesn't affect binary sizes. It only affects in the sense that it discourages us from pulling in new things.

On the second bullet about smaller binary sizes, I know you've suggested xargo.

I haven't really suggested xargo for this, I suggested it for compiling the stdlib with different flags, which you previously (incorrectly) felt was possible via no_std.

Code-wise, we get this tier basically for free by using no_std_compat, as explained earlier.

Not if we try and do the allocation-fallback thing suggested earlier.

We don't get this for free. We get this for free for our crate, and then need to worry about it in dependencies. Tooling around this is largely focused around no_std and not no_std+alloc. I'll say this again: no_std+alloc is not really a first class use case in the Rust ecosystem yet, and I'd rather not be in a position of pioneering it. Back when no_std was getting established there were a lot of problems, and I forsee the same here. A shim crate helps, but doesn't fix everything. At least no_std has always had a large set of users who wanted it, no_std+alloc is a super niche use case. I'm pretty strongly of the opinion that this is going to be a nontrivial cost for us.

@zbraniecki
Copy link
Member

  1. no_std+alloc = useful for WebAssembly, FFI, and environments where I/O is unavailable.

@fitzgen stated that he does not think no_std+alloc is the best practice for WebAssembly.
I have not seen any proof that it is the best practice for FFI.

I'd prefer to start with (3) and introduce (1) where needed, as Manish suggests.

@sffc
Copy link
Member Author

sffc commented May 22, 2020

In ICU, we usually fall back to a heap allocation in this case. What do you suggest as a best practice to handle such situations?

I think having no_std be able to fall back to a panicky variant seems okay. Ideally one that returns errors, but well documented panics work too.

We could try doing this via a shim crate. It's also possible to start off first with the "no_std users don't get this" and gradually make things compatible.

Is there prior art for this?

We don't get this for free. We get this for free for our crate, and then need to worry about it in dependencies. Tooling around this is largely focused around no_std and not no_std+alloc. I'll say this again: no_std+alloc is not really a first class use case in the Rust ecosystem yet, and I'd rather not be in a position of pioneering it. Back when no_std was getting established there were a lot of problems, and I forsee the same here. A shim crate helps, but doesn't fix everything. At least no_std has always had a large set of users who wanted it, no_std+alloc is a super niche use case. I'm pretty strongly of the opinion that this is going to be a nontrivial cost for us.

OK. "I'd rather not be in a position of pioneering it" is sensible. I still think no_std+alloc has potential for the ecosystem as a way to express pure algorithms with minimal dependencies, but if we were to go down that path, we would all need to be onboard with committing to it. It's clear that I'm alone in thinking that this is a good idea. Until someone else comes out of the woodwork in favor of no_std+alloc, let's move on.

@Manishearth
Copy link
Member

Is there prior art for this?

Not that I'm aware. We'd probably want either a panicky smallvec/tinyvec variant, or something that is unconditionally vec on std builds and unconditionally a panicky array on non-std. I have definitely seen no_std ecosystem crates that implement the "panicky array" stuff before which we can join up, I just don't recall where.

@sffc
Copy link
Member Author

sffc commented May 28, 2020

We discussed this subject at this week's ICU4X meeting. Main conclusions:

  1. We think that pure no_std is too difficult to implement out of the box in ICU4X, especially since some of our most fundamental types like Locale require allocation. However, when designing traits and interfaces, we should make them no_std-friendly so that we can expand in this direction more easily in the future.
  2. We will investigate Clippy-based solutions to restrict our usage of std libraries. I suggested a Clippy test that takes a whitelist of allowed std dependencies and fails if you use one that isn't whitelisted. @Manishearth also said @fitzgen may have written tests that ensure WebAssembly compliance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-design Area: Architecture or design C-meta Component: Relating to ICU4X as a whole T-docs-tests Type: Code change outside core library
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants