What Is the Minimal Set of Optimizations Needed for Zero-Cost Abstraction?
robert.ocallahan.org
robert.ocallahan.org
In a world where compiler architecture had actually advanced, you would download a precompiled binary alongside the source, identical to what you would have produced with a clean build, and that binary would be updated incrementally as you type code. If you made a change affecting a large part of the binary that couldn't be applied immediately, JIT techniques would be used to allow you to run and test the program anyway before it finished compiling.
There is no fundamental reason why anyone should ever have to wait for a compiler. And if you didn't have to wait, then it would free the compiler to spend potentially much more time doing optimizations, actually improving the final binary.
The zapcc project shows a bit of the potential for improvement in build times, though it's just scratching the surface. https://github.com/yrnkrn/zapcc
- an ex-Scala core contributor's lament about why compilers have to be incremental: https://www.youtube.com/watch?v=TS1lpKBMkgg&t=42m
- a Rust core team member talking about responsive compilers: https://www.youtube.com/watch?v=N6b44kMS6OM (basically the "low-level" implementation of the talk at the previous link)
AFAIK Microsoft's toolchains do this but they don't do it well. It's a frequent source of headaches.
[1] https://arxiv.org/abs/1312.0658 [2] https://blog.functorial.com/posts/2018-04-08-Incrementally-I...
#define TURNONFEAT 1 #include <xyz.h>
or #define TURNONFEAT 0 #include <xyz.h>
That can create wildly different includes. The upcoming modules feature in C++ may change that. But unfortunately at this point we get to live with a recompile.
Pre compiled headers took some of the sting out of it. It would be nice if they could say 'in this case at this point these flags existed and I used them I will hold onto that for this header'. That way you compile it once and the rest of the project can change things in and out. The pch way seems more 'i know I will not change the flags ever'. It was a good start. Maybe a new object type and it could even live on between compiles?
I suppose there's the problem of optimization across module boundaries, but if you solve that problem for compilers, might as well solve it for linkers.
What you're describing sounds like my current best practice for developing iOS apps. I have multiple static libraries for separate logical code units that don't have to be recompiled when unrelated code is changed. This is for C, C++, ObjC and Swift. All iOS package managers have the option to download compiled binaries, as well.
The main downside is that it's easy to make your image out of sync with your code. That is, the state of the image during development (global variables and their values, functions, classes, environment settings) may end up different than what you'd have if you restarted the image and reloaded the code into it - which can lead to an unexpected cascade of bugs the next time you restart the image.
In practice, you can guard against it with unit and integration tests, as well as just reading warnings the compiler gives you.
Still, I don't think being image-based will help C++ as long as templates work the way they do - templates are code generators, so each time you change one, you have to recompile all its potential users. The way CL implementations tend to work, "building the image" is similar to C++'s linking process - i.e. whenever a Lisp file is compiled, the compilation output is cached, so it can be loaded quicker if the file didn't change. But if you change a widely-used macro, you end up having to recompile almost everything from scratch, which is similar to C++ and changes to header files.
Apparently other platforms never bothered that much into making work properly.
[1] https://github.com/StanfordSNR/gg Sort of checksums-and-argument-lists to make gcc deterministic, as for a cache, but farmed out so `make -j100` runs on amazon Lambda.
If you are able (somehow) to encapsulate this state, and save and load it faster than you can regenerate it, that’s a win. You can call it caching or memoisation or “incremental compilation” if you really enjoy that sort of thing, but this is such a basic concept that if you cannot understand it, you are going to have a lot of other problems.
If you're going to be condescending like that, it would be nice if you could provide a concrete example of how to incrementally compile vector<T> in the general case in such a way that it's useful and reasonable to redistribute (that is, not tied to a specific architecture or compiler) instead of just waving your hands.
If you're writing a compiler and want specific advice about how to do this, by all means reach out via email and we'll work something out.
Are you joking here? While I'm typing is the worst time to compile code. Much of the time, my code contains syntax errors because, for example, I'm in the middle of writing a line of code. I even write such "bad" code to the filesystem several times before deciding to compile, so I'd be quite annoyed if my computer churned on every file-save.
Where does this blob come from, how is it that it can exist when you haven't written the code yet?
I know that the Internet makes it seem as if data access is automatic and magic; however, fundamentally, computing is a physical process. Bits don't shuffle by magic, and they can't even be targeted for download to avoid compiling before compiling and checking the result against some sort of sample or list for whether or not the chunk of code already exists. No avoidance of compilation achieved.
Compilation/interpretation is not something you're ever going to make go away unless you like programming with straight 0's and 1's.
- Inlining
- Common subexpression elimination
- Dead code elimination
- Monomorphization
I get the impression that 2 and 3 come out of an SSA middle end fairly straightforwardly, along with the article’s copy propagation and dead store elimination.
The biggie is monomorphization, which is part of the language in C++. The alternative is uniform representation of generic values, which implies boxing and indirection instead. I understand Rust does not hide representations enough for the compiler to be able to choose whether or not to monomorphize. Interestingly, the Go generics discussion seems to be heading towards a design where the compiler does have this option.
Any chance you could expand on that?
That makes me wonder what Java does these days. I haven't used Java much since about v1.6. Java took a different approach than C++ and Rust. I remember when generics were introduced, they were basically syntactic sugar for type casts (for backwards compatibility). That implies that Java at the time didn't monomorphize generics. I think the language has evolved a lot since those days, so I wonder if it does any monomorphization today.
Generally, the jit can specialize the code making assumptions about the real type under interfaces/polymorphism, which types the generic parameters will be, or even which target virtual dispatch will always hit.
But for the objects themselves, it never specializes layout in any way (short of removing an allocation entirely).
... I think. I'm no expert in this, ask chrisseaton.
A monomorphized function looks like this:
fn f1<T: Display>(v: &T) -> String {
format!("{}", v)
}
A dynamic dispatched function looks like: fn f2(v: &dyn Display) -> String {
format!("{}", v)
}
Note that we've added the dyn keyword, which tells rust to treat this as a trait object and use dynamic dispatch.This is actually separate from boxing (these examples are just using normal unboxed references), although typically you would use trait objects with boxing as unboxed trait objects are awkward to work with.
See https://godbolt.org/z/PvM3ox for an example of the compilation: we get two versions of f1 specialized for u32 and u64, but just one for f2.
Rust's standard library uses a pattern (written manually) of "hoisting" generic code out of large functions, e.g.
fn generic<P: AsRef<Path>>(path: P) {
non_generic(path.as_ref());
}
fn non_generic(path: &Path) {
// lots of code
}
That's cheaper to compile and reduces binary size compared to having one large generic function monomorphized for every AsRef<Path> type.It'd be great to have this as a compiler optimization to automatically have "half-monomorphic" functions.
"Given this, what techniques can be used to reduce template instantiation time? One technique is called "hoisting." This is a generalization of the "wrappers for pointer containers" technique that showed up as a tip within the past week or so. The idea is quite simple: when writing a template class, consider each method in turn. If the method does not depend upon the template parameters, split the template class into a non-template base and a template derived class. Move (hoist) these parameter independent methods up into the base class. Note that with experience, you will begin to recognize opportunities for "hoisting" even in cases where methods initially appear to depend upon template parameters."
See also "thin template" (demonstrating the technique for containers, together with a Symbian OS adoption example, https://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Thin_Templ...) as well as "Minimizing Dependencies within Generic Classes for Faster and Smaller Programs", Dan Tsafrir, Robert W. Wisniewski, David F. Bacon, and Bjarne Stroustrup (ACM OOPSLA 2009). https://www.stroustrup.com/SCARY.pdf
"Reducing bloat by replacing inner classes with aliases can be further generalized to also apply to member methods of generic classes, which, like nested types, might uselessly depend on certain type parameters simply because they reside within a generic class’s scope. (Again, causing the compiler to uselessly generate many identical or nearly-identical instantiations of the same method.) To solve this problem we propose a “generalized hoisting” design paradigm, which decomposes a generic class into a hierarchy that eliminates unneeded dependencies."
> Tail calls: I can't think of a Rust or C++ abstraction that relies on TCO to be zero-cost.
This is an interesting one. Doesn't the absence of TCO mean that there are a lot of (recursive) abstractions that cannot ever be zero cost ? First thing that comes to mind is processing of composites (for example serialization of a protobuf type definition). There are others.
https://dev.to/seanchen1991/the-story-of-tail-call-optimizat...
> Tail calls: I can't think of a Rust or C++ abstraction that relies on TCO to be zero-cost.
Rust doesn't have TCO, so it's hard to rely on something that doesn't exist (unless it's on nightly and I missed it).
There's Spiral [0]: a language where inlining is the default and also a first-class concept, much like immutability in new popular languages like Rust.
As a bonus, the commit messages contain a whole journal worth of notes.
Register allocation is classically a win for compile speed. It takes less compiler work to put values in registers than to emit all the loads and stores and pushes and pops for putting them on the stack.
Eg, I do a lot of financial programming, so I need a decimal type, which is like a float, but base 10 so math with no floating point errors.
When I turn on compiler optimizations on zero cost languages, I get roughly a 15-20x speedup, depending on what it is. Interestingly, languages without zero cost abstractions, but are still quite fast, eg Java, run at the speed of the zero cost languages with optimizations turned off.
Zero cost is not helpful in most use cases, but in my situation it gives quite a large speed boost. Sadly, I feel like I'm locked in to using Rust (or C++) and don't have much of a choice. I suspect Haskell is going to be quite a bit slower for creating custom types unfortunately.
In finance (and quant work) you don't want a mistake. You want what you code to have provability, so you know what you write is what you get. This is super important. Speed in pure finance is not important.
In quantitative finance the common practice today is companies roll out their own programming language that has provability like Haskell, but is closer to the speed of C++. These languages tend to be a big closer to lisp than Haskell, but it depends from company to company.
LLVM lets you configure an optimization pipeline of passes. I'd hack up my own -O flag and pipeline. It would be cool to see these suggestions tested out.
* https://doc.rust-lang.org/stable/rustc/codegen-options/index...
* https://doc.rust-lang.org/stable/rustc/codegen-options/index...
I have seen Gcc dismantle one loop into two adjacent loops, the first to pre-compute a table for the second loop. My jaw was on the floor.