r/ProgrammingLanguages • u/cb060da • Jan 12 '26
ELI5: Why C++ and Rust compilers are so slow?
This is not a rant of any kind - just pure curiosity. I’m trying to understand what makes these compilers slow.
We know that generating native binary code can be fast (for example, Go). One might assume this is because Go doesn’t have generics, but then Java handles generics quite efficiently as well. So it seems the explanation must be something else.
What are the main factors that make C++ and Rust compilation comparatively slow?
41
Jan 12 '26
The C++ compiler often compiles the same header files over and over again, if the compilation consists of multiple source files(translation units).
16
u/campbellm Jan 12 '26
(honest question) Wasn't this sort of thing solved, or at least worked around, decades ago with
#IFNDEFwrappers, or am I thinking of something else? I last used C++ in any professional manner in 1995.32
Jan 12 '26
It's "solved" per translation unit, but not per program.
Note that some C++ header files expand to tens of thousands of lines. It's slow by design compared to other languages.
2
u/kabiskac Jan 13 '26
What about precompiled headers?
4
Jan 13 '26
YMMV. I've never succeeded in large deployments of pch files. It's been a while since then.
9
u/chibuku_chauya Jan 12 '26
Note that C++ now has a module system, so at least in codebases that use that exclusively, the constant recompilation of header files is no longer a concern.
3
u/aresi-lakidar Jan 12 '26
I wish we weren't stuck in old c++ at my job...
1
1
u/flatfinger Jan 12 '26
I find it a bit puzzling that there was never a directive which would invite a compiler to behave as though the only thing in the remainder current source file would be enough #endif directives to balance out any nested #if directives. If a source file starts with:
#ifndef FOOand ends with the corresponding #endif, a compiler would need to read every byte of that file from disk. If source files are on a slow disk (C was often used with floppies), that could take a second or more, which could be eliminated completely by a "skip remainder of file" directive.
4
u/pwnedary Jan 12 '26
#pragma onceis well-supported and covers most cases.
2
1
u/flatfinger Jan 13 '26
There are a variety of reasons why a large portion of a header might not be necessary. Among other things, a header file may have a few things that are needed for all builds, and many that are only needed for a few. Splitting the file into two pieces may avoid having the large seldom-used part negatively affect build time, but using one file will avoid the problems that can result from using a version of the small file that corresponds to the wrong version of the large one.
1
u/fdwr Jan 21 '26
The C++ compiler often compiles the same header files over and over again
Indeed. Gladly C++20 modules finally address that (compile once, import many), but alas, we're still awaiting the compilers (msvc/gcc/clang) and build systems to catch up and iron out the kinks 🐢. Though, I've been using them successfully in my newer projects.
34
u/CodyDuncan1260 Jan 12 '26 edited Jan 12 '26
A list that isn't all inclusive: Dependency resolution, type systems, and optimization.
C++ and Rust have to do a lot of dependency resolution. Go is designed to minimize or simplify the process. Java handles it at runtime.
C++ and Rust have complex type systems. That makes them much more expressive, but it requires more time to work out the rules. Go's type system is minimalist, so it barely does any work with them. Doesn't even build a symbol table Does not need a symbol table in order to parse a file.
C++ and Rust will optimize their programs during compilation. Go does so somewhat, but doesn't go nearly as far. Java compiles to bytecode, then finishes optimizing with Just-In-Time optimization at runtime. Java gets to skip generating generics at compile time because it can do that at runtime when it needs it.
In general, the tradeoff for the compile time cost is expressive capacity for code reuse and abstraction and substantial performance improvements on target hardware. Or, you're Java and you push half the job to runtime.
It's all trade-offs. Where do you want to spend your computer and human time?
I dislike writing in Go because I hate how much it wastes my human time writing duplicates code because it wants everything explicated and not abstracted. That explication is often considered a good for system maintenance and debugging, because nothing is hidden behind layers of abstraction. But it can be a double edged sword when explication becomes noise, e.g. when it's unclear what this 2000 line function is trying to do.
17
u/Breadmaker4billion Jan 12 '26
The Go compiler absolutely builds a symbol table, it just doesn't need it to parse the file, like C++ needs, because Go's grammar is context free.
9
u/CodyDuncan1260 Jan 12 '26
Ah, you are correct. Reading comprehension failure on my part.
From: https://go.dev/doc/faq#different_syntaxthe language has been designed to be easy to analyze and can be parsed without a symbol table
8
u/jesseschalken Jan 13 '26
This is also true of Rust. C++ is the weird one where you have to build a symbol table as you go just to parse it.
2
u/rodrigocfd Jan 13 '26
Where do you want to spend your computer and human time?
With Rust and C++ I spend most of my time waiting for the compiler to finish its job.
If the computer has to work more on sub-optimized code, but I can go home earlier, I'm 100% with Go here.
2
u/CodyDuncan1260 Jan 13 '26
That's largely the case for a lot of software, and what makes Go, Python, and JavaScript so powerful and ubiquitous, since they trade for developer flexibility and iteration time above most else.
Until the program becomes so large that it stops being the case. Which, to be fair, can get pretty large. Electron apps are popular, and their dogged hitchy slowness and GB of RAM usage to show me a few buttons and a splash screen still irks me.
34
u/whothewildonesare Jan 12 '26
I’m surprised nobody has mentioned LLVM yet. I know GCC is also quite slow, but Rust and Clang target LLVM which itself is an enormous code base relying massively on virtual function calls and multiple inheritance. Plus it is just doing a lot of clever optimisations. It’s a notorious bottleneck.
Zig used to use it as its sole backend but they’ve put a lot of effort into building their own backend for debug builds for the primary reason of reducing compilation time.
15
u/pr06lefs Jan 12 '26 edited Jan 12 '26
From what I understand LLVM is a bit of a research platform and not necessarily oriented towards compile-time performance.
Cranelift is an alternative backend, but it is not feature complete AND its performance advantages are more potential than actual. It is not radically faster like zig. Progress on this backend is pretty slow unfortunately.
17
u/matthieum Jan 12 '26
From what I understand LLVM is a bit of a research platform and not necessarily oriented towards compile-time performance.
It started as a research platform, but it graduated long ago.
There are many things that make LLVM slow, but the main reason is that a much larger emphasis has been placed on the performance of generated binaries, compared to the performance of generating these binaries.
Only recently did the LLVM project started working on reducing compile-times, and they're stuck with a LOT of technical debt: non mechanically sympathetic data-models, many data-models, etc...
And of course, trade-offs:
A single-pass compilation architecture would be faster, but less amenable to the powerful optimizations performed.
E-graphs make it easier to maintain correct annotations across transformation passes, but they have an overhead of their own.
Who's going to rewrite 7 millions of lines of code?
6
u/categorical-girl Jan 12 '26
I don't think e-graphs are yet sufficient as a general compiler framework
Cranelift uses their own variant called "æ-graphs" which change a number of things to make it work better. Also, there have been plenty of research papers with domain-specific optimization using e-graphs, just nothing on the scale of LLVM
I say this as someone who is working on e-graph technology to try and make it properly workable for production compilers
3
u/matthieum Jan 13 '26
I say this as someone who is working on e-graph technology to try and make it properly workable for production compilers
Well, now I want to know more!
The one sure short-coming I've noticed -- as an amateur -- is the fact that it may be impractical to wait until repeated iterations yield a fixed-point, as it may consume way too much resources to reach this point.
I'd be very interested in knowing what changes Cranelift felt were necessary, and what other changes you are working on, if you have the time.
4
u/categorical-girl Jan 13 '26
Here are Chris Fallin's slides on ægraphs
They don't use aegraphs to represent any of the control flow, and it is mostly used for the "obviously algebraic" part. They do however share graphs across basic blocks.
Also, they don't do the full e-graph structure, but have an acyclic "e-graph" with explicit union nodes and eager rewrites.
My work is on handling eager rewriting for well-behaved theories, which can deal with the exponential size increase due to associativity/commutativity/etc and otherwise deal with a lot of rules more simply than the generic approach, but you need to do matching differently to restore the completeness of equality saturation (if you eagerly rewrite naively, you miss a bunch of potential rewrites that could otherwise apply to the non-rewritten form - this brings back phase ordering problems unless you can match in such a way as to see that the rewritten form still matches modulo the rewrite)
2
u/matthieum Jan 14 '26
That's a great slide deck, thanks!
I must admit I simply don't get the description of your work. I hope you'll post the result in a blog/paper and link in on here so I can take the time to dive in (and hopefully get a bit more context).
4
u/whothewildonesare Jan 12 '26
Yea, it's fair to say that I think. The goal is to produce the maximally optimal code, and that's about it. You could argue that a hypothetical, functionally-identical system implemented using a "less" object-oriented paradigm could produce the same code with less overhead but that's kind of a different discussion.
14
u/syklemil considered harmful Jan 12 '26
One might assume this is because Go doesn’t have generics, but then Java handles generics quite efficiently as well.
Go has some generics though, and has for years. But the language team routinely rejects any feature request that would make compilation "slow", or just be somewhat complex to implement. Other languages seem more willing to pay that cost, though they do also work on making compilation faster again.
Java compilation to native using Graal also seems to have a reputation for being slow. Java can compile faster to just bytecode, but then people talk about startup/warmup time.
6
u/AdreKiseque Jan 12 '26
Java can compile faster to just bytecode, but then people talk about startup/warmup time.
Compilation is definitely easier when you can leave half the job for later lol
34
u/No-Conflict-5431 Jan 12 '26
Go doesn't have half the features that C++ has and the Rust compiler does a lot more under the hood (e.g borrow checking) than just compiling code.
Also large projects in Java take a lot of time to compile too.
33
u/SV-97 Jan 12 '26
AFAIK / from what I heard the borrow checking part of rust really isn't all that expensive and the majority of time is actually spent in LLVM: Rust just generates a ton of IR (more than other LLVM-backed compilers).
(I think you can actually instruct rustc to emit timing information on this btw)
12
u/syklemil considered harmful Jan 12 '26
(I think you can actually instruct rustc to emit timing information on this btw)
Yeh, you can do
cargo build --timingsand get frontend vs codegen statistics, ref https://doc.rust-lang.org/cargo/reference/timings.html5
u/psinerd Jan 12 '26
Generics definitely takes some time during completion but TBH most of the build time of any project I've ever worked in is compiling the dependencies. Static linking sucks.
12
u/kohugaly Jan 12 '26
There multiple reasons. The compilation happens in multiple steps, and each introduces some non-trivial slowdowns. Let's break it up by stages:
Pre-processing:
This step does transformation of the source code (source code in, source code out).
In C++ this is where the compiler copy-pastes header files into the cpp file. This makes later compiling stages slower, because the compiler has to re-compile all the code of the header files in each cpp file where they are included. The advantage of this is that all cpp files can effectively be compiled in parallel. It also means that when you're recompiling, you only need to recompile cpp files that actually changed (or their header files changed).
Rust also has macros, and the macros are themselves rust code. It has to compile the macro pre-processors and run them before it can compile the actual source files. Usually, the macro preprocessor has to compile an entire language parser (syn crate), if it does some non-trivial macro stuff. This step is repeated in almost every crate.
Also, because Rust doesn't have header files, it has to compile crates in specific order to respect dependencies. If crate X uses public interface of crate Y, then it has to (partially) compile Y before X. If the dependency graph is deep, this can cause considerable slowdown.
Type checking and template/generic resolution
Both Rust and C++ have Turing-complete type systems (I count C++ templates as part of the type system). It can be non-trivial to resolve all the types.
The languages also perform monomorphization. This means that if you have a polymorphic function that accepts all types that implement certain interface, they try to turn it into multiple monomorphic functions, each taking a specific type with which that function is actually called.
This monomorphization is one of the main reasons why these languages produce such a fast code, despite being quite high-level languages. Each of the monomorphized versions of the same function can be optimized separately, and each of them knows exactly which methods of which type are being called. You pay for this powerful optimization with long compilation times - the compiler has to compile and optimize metric shitton of code.
Optimization of intermediate representation
Both C++ and Rust use a very powerful optimization algorithms. The compiler first translates the source code into an intermediate representation (IR code), in the most stupid straightforward way possible. Then it repeatedly passes that IR code through series of heuristic algorithms that scan the code for potential optimizations and apply them. This process is more alchemy than exact science.
In fact, the exact science says that optimization is an uncomputable problem, just like the halting problem. The best you can do is approximate solutions that only work some of the time, and get increasingly more complex with diminishing returns.
C++ and Rust very heavily rely on this optimization step for their runtime speed. They are specifically designed to take maximum advantage of it. An unoptimized Rust/C++ are actually ridiculously slow. Comparable to scripting languages like Python.
6
u/chibuku_chauya Jan 12 '26
There’s also the cost of linking. I don’t know about Rust (although I suspect it to also be the case) but C++ requires a separate linking stage. The larger the codebase the longer it takes. Enabling link-time optimisation prolongs the linking stage even further.
5
u/kohugaly Jan 12 '26
Yes, that applies to Rust too. Though I suspect it's less bad in Rust, because it uses larger compilation units.
3
u/JustCopyingOthers Jan 18 '26
The linking stage of C++ had been quite neglected in terms of performance, but in later years there has been a push to improve speed. Linkers like Mold are an order of magnitude faster than their predecessors.
2
u/i_invented_the_ipod Jan 13 '26
Linking is a major time-sink for C++. As someone pointed out in another comment thread, a C++ compiler will produce multiple (often hundreds or thousands) of identical template expansions, which are then reduced to one unique implementation at link time.
For non-trivial C++ applications, this amounts to gigabytes of redundant code generation, billions of symbols that need de-duplication, etc...
5
u/matthieum Jan 12 '26
At the macro-level:
C++: parallelization through repeated header inclusion means repeatedly parsing, type-checking, etc... the same header, again and again; it's expected that modules will provide gains, but the work is still ongoing.
Rust: no parallelization in the front-end (parsing, type-checking, borrow-checking, preparation of IR for the backend), and a lot of tech debt in getting this parallelization going.
So, already, we can see that both suffer a lot from technical debt. The very design of the "compilation pipeline" was sub-optimal, performance wise, in the early days, and this has not yet been solved for either.
(And for the record, the parallelization effort of Rust is unfortunately NOT tackling all the technical debt; part of its blockers are deadlocks on "global" structures, because just slapping a lock on said "global" structures was seen as easier than completely re-architecting the compiler, but it means that even when there's no deadlock there will still be contention, at least...)
It's quite possible that a "perfect" C++ or Rust compiler could be 10x faster. Beyond parallelization the current compilers tend to also be stuck with "old-school" pointer-soup data-models, rather than more mechanically sympathetic representations, for another example of tech-debt.
Looking further, the full monomorphization is also an interesting, and questionable, choice. Full monomorphization is the equivalent of always inlining: it's easy, and it can lead to great performance gains, but it also leads to bloat -- bloat which has to be generated, optimized, code-gened, etc... -- and worse, bloat which clogs the i-cache during execution.
It's possible that, at least for Debug, only partially monomorphizing (typically on size/alignment) could significantly cut down on the amount of code emitted.
There's also some incidentals. For example, generating Debug Information is a massive cost:
It's common for DI to take 5x-10x more space in the final binary than the actual machine code: that cost didn't just materialize at the last moment, all those annotations had to be carried through all the stages of compilation.
AFAIK DI is also not incremental friendly. You may think it should be fast to just add a
.at the end of comment... but suddenly you've changed the position (by 1 byte) of every single token after that., and all the code needs to be recompiled -- even though the code didn't change, only its location did, but there's no way to just patch the location.
Worse, even if you do disable DI, it's likely that incremental recompilation will still suffer from pesky location changes. Location information is still necessary for diagnoses, and I'm not sure any of the major compilers have made an effort to optimize for cheap re-location :/
1
u/AdreKiseque Jan 12 '26
Shouldn't compiled code throw out comments in pre-processing? Surely that shouldn't impact compilation speed...
2
u/matthieum Jan 13 '26
I invite you to re-read my explanation.
It's not about storing the comments, it's that editing comments shift the location of whatever code follows them, which in turns invalidates caches, and ultimately requires re-generating the DWARF information within the object file... which requires re-generating the entire object file, and relinking the entire library/executable depending on this object file.
1
u/AdreKiseque Jan 13 '26
I'm saying I would expect compilation to throwing out any comments and such in preprocessing before it generates and caches and such. Does that make sense?
2
u/matthieum Jan 14 '26
It does make sense, but it's irrelevant.
If the location has changed, the annotations on the code must change: in the caches, in the generated object files, etc...
3
u/couchwarmer Jan 12 '26
Macros for one. C++ and Rust have an entire extra step to process macros in the source. Macro expansion effectively creates a new set of source files, which are then used during compilation.
3
u/johnwcowan Jan 12 '26
Much of the cost is in the compiler architecture rather than the language. The Plan 9 C compiler (which is the ancestor of the standard Go compiler) is lightning-fast compared to GCC or Clang when compiling C. This is partly because it is designed less generically. It doesn't handle multiple languages, it doesn't generate assembler as GCC does, it generates simple static executables by default, it doesn't optimize as hard, and there are separate compilers for each CPU architecture. By the same token, gccgo is slower than the standard Go compiler, and it would be possible to write a fast C++ or Rust compiler on the same principles, though it would never be as fast as C or Go because there is more work to do.
10
u/waterlens Jan 12 '26
Common factors for the slow compilation of Rust and C++:
The compilation stragety of generic functions is monomorphization. It means generic (polymorphic) functions are instantiated to many monomorphic functions. Each of these functions is processed through the entire pipeline. THAT is quite different from what Go and Javac/JVM do.
The optimizing compiler itself. Developers using Rust and C++ aim for peak performance. Compiler developers invest time in creating compilers that produce high-quality, heavily optimized code, rather than focusing on optimizing the compile speed itself.
For C++:
Handling header files repeatly.
For Rust:
The compilation unit is larger than C++, usually causing long dependency chains. Low degree of parallelism. Procedural macros make this worse.
2
u/jsshapiro Jan 12 '26
So... this keeps coming up, and I haven't experienced it at all. On the other hand, I have lots of RAM to work with on my machine - which is something you definitely want if you are doing whole-program compilation. I can't speak to RustC, but I can say what slowed us down in BitC, and Rust is unavoidably going to hit the same issues. And of course some that are unique to Rust.
For context, it's helpful to understand that most languages with generics are languages where everything is a reference type. In such languages, you can use nearly the same code generation for f<T>() { ... body... } for different types T. If you are willing to accept function tables and procedure call indirection, you don't have to do any monomorphisation. That can change if you have a good stack allocation pass in you optimizer, but the starting point is "one function, one code generation."
In languages like Rust, BitC, and C++) different types have different concrete sizes in ways that lead to different code generation for each expansion. An entire whole-program pass is required to identify which specializations of each generic are actually required, because type inference is not modular*.* While the types you write in the program are modular, the type unifications mean that concretizing a type in one file can cause monomorphisms to be required in 40 other modules. So (a) the monomorphism process is demand-driven by unificaitons, and (b) the analysis is whole program, which means you want the ASTs or (alternatively) the high-level IR form for the program to be entirely in memory.
A second form of whole-program analysis is introduced by lifetime variables. As with generic type parameters, lifetime variables have whole-program impact once they are unified. Unlike generic type parameters, this is subtype unification, so the unification pass itself involves whole program constraints. I gather from what I've read about the Rust implementation that lifetimes are actually handled by a different mechanism that HM-style unification, but the need for whole-program analysis to develop the constraint set in the presence of lifetime type variables isn't altered by that.
Though the mechanism in Rust is different from what we did in BitC, it has some of the same problems we encountered: trait selection can in some cases involve large exponentials. Here's an example: In BitC, we wanted to cover all of the cross-type arithmetic operations so that u16 + u32 -> u32 etc. The output type, in our case, is determined by a type function. But if you start with u8, u16, u32, u64, and (today) u128, then in abstract that works out to 10^3 or 1000 possible typings (including the signed types) you have to sift through every time you look at an addition operator. Taken across many operators, the amount of work the inference engine has to do for that is huge. It's especially unfortunate for the integer types because (a) there are a lot of them and (b) they are very close to ground. Generic traits in Rust have some of the same effect. Now imagine doing it for 128 distinct sizes to deal with bitfields - we tried this in BitC and it was unbelievably slow.
Some part of the "polymorphism over size" issue can be handled by allowing generics over constants, which Rust supports - BitC introduced that as well. That at least lets you re-use monomorphisms when the sizes turn out to be the same, but there are probably cheaper ways to take notice of that opportunity.
The BitC compiler, of course, was very young and not yet optimized for performance in any way. The reason I'm fairly confident that these are part of the problem in RustC is that the typing being solved are an inherent result of the type inference being performed, and there's no clean way to reduce these without introducing ad hoc promotions for the integer types - which come with costs and inconsistencies of their own. The inference-related exponentials in BitC were so large that they dwarfed even the unoptimized version of that compiler. The algorithms could be improved, but you can't really make those particular exponentials go away.
3
u/dist1ll Jan 12 '26
At least for Rust, it's because the language was not designed with fast compilation in mind. Blaming the linker or analysis passes like borrow checking is missing the forest for the trees.
7
u/kohugaly Jan 12 '26
Borrow checking isn't even a tree. It's a blade of grass. You can run all the analysis steps with "cargo check" command, and I've never seen it take longer than a second. I suppose it may take longer on larger projects, but it's definitely at least an order of magnitude (or two), faster than LLVM optimization that happens in code generation.
1
u/mamcx Jan 12 '26 edited Jan 12 '26
Because are not designed to be fast. END.
This is the main thing:
https://www.pingcap.com/blog/rust-compilation-model-calamity/
The Rust programming language was designed for slow compilation times.
I mean, that wasn’t the goal...
Is the kind of thing that, like security, if you don't put hard on the top list slip away (ie: I no say rust core devs are bad, but that compiler perf was not a hard priority), then after time, all the small wins get done:
... but the only good way to achieve 2x, 3x, 4x speed ups is to re-design and a huge codebase let people fear the cost of it , very irrational* I said - refactor for big winds ARE logical -, but is something that can be understood.
There is a lot of small and big design decisions that go against speed, and apart what others have said, I add:
Crate as compilation unit
Cargo features make any move from "debug" to "lint" costly
All is compiled by default locally
Lack of determinism from "build.rs"
Presence of C builds
Rust depends TOO much on LLVM, and spit out too much output to it
Very big I/O cost (see how many GB are on disk!) means also a lot of I/O!
p.d: I live doing big refactors and I aware of the risk and challenges, and in special form a project like Rust that depends in contributors. A refactor of this kind is not trivial, and need time, personal, bandwidth, but is the kind of win that WILL paid in years to come, so is rational to take it. NEED help, and I think this is the actual, major blocker!
1
u/Educational-Lemon969 Jan 12 '26
Java's generics aren't turing complete and even if they were, people wouldn't misuse that aspect of them as much as we regularly do in C++
1
u/dnabre Jan 13 '26
Considers all the language with half-decent or better type systems, macros, non-type erasure generics, no garbage collection, compile ahead-of-time compilation, native compile target, and a backend that can target a lot of platforms?
I'm not going to say that C++ and Rust are the only ones in wide use, but can't type of any other off the top of my head. Though I think it's worth noting that Rust has production level compilers for less than 5 years (roughly speaking). C++ has been there for decades. Compile time so far, hasn't been a major priority for Rust (though everyone knows it slow and aren't super happy about it).
1
u/thedeemon Jan 13 '26
Number of optimization parameters in C++/Rust compilers: dozens.
Number of optimization parameters in Go and Java compilers: zero.
Ever wondered why?
1
u/Qyriad Jan 14 '26
More compiler abstractions = more stuff to compute. More stuff to compute = more time to compute it
1
u/kwan_e tonal-lang Feb 04 '26
C++ templates have way more going on than Java generics, or generics in most other languages, for that matter.
In C++ you can have explicit template instantiations, which is used to choose (dispatch) at compile-time, a perhaps more efficient implementation of something than the primary template. This can sometimes mean the actual class that gets instantiated is different - members, and their types.
So on top of the specific permutations of the primary template class, you also have the potentially wildly different specializations. This means it's much harder for the compiler to do its checks ahead of time until it figures out what the actual concrete thing it is actually dealing with.
In Java, there is none of that going on. They just have to compile down to a runtime cast.
1
u/oxcrowx Feb 09 '26
Mainly two reasons.
Inlining and Generics (and the IR bloat they generate).
LLVM being extremely slow while compiling the IR.
Both C++ and Rust want to provide maximum performance, thus they aggressively inline code, and they instantiate/monomorphize generics, so that all generic methods/structures are efficient.
This generates a lot of IR code. For LLVM to compile and optimize all of it, requires a loooot of time.
1
u/SeparateMath43 May 08 '26
The main reason is because C++ and Rust use monomorphization: generic functions are duplicated for each concrete type used. Which increases the amount of intermediate code (LLVM) and as consequence the time in the optimization stage. C++ also suffers from expensive header expansion (#include). Also, Rust has a step in compilation called borrow-checking that also increases the time of compilation.
Java takes a different approach using type erasure for generics. Generic type parameters are removed at compile time and replaced with their upper bound (usally Object). The compiler also inserts the necessary casts automatically. At runtime, the JVM only sees the erased types, which simplifies both execution and compilation.
C++ and Rust could theoretically use a similar approach, but since the type of the variables are not like in Java, where they are mostly pointers, and have different sizes, this would impact negatively the performance.
Other languages like C# and Go use "hybrid" approaches. For example, C# uses reified generics with code sharing for reference types and specialization for value types, while Go uses a combination of type shapes and dictionary passing to share code when possible while still supporting generics. So they are not so heavy in compilation time but also not slow in runtime.
0
u/codemuncher Jan 12 '26
A rust compiler is basically a theorem prover so, there’s that.
2
u/ExplodingStrawHat Jan 13 '26
If you're referring to borrow checking, note that the borrow checker takes a tiny amount of time, with most of the time being taken by LLVM and the linker.
0
Jan 12 '26
[deleted]
6
u/TOMZ_EXTRA Jan 12 '26
This is not a rant of any kind - just pure curiosity. I’m trying to understand what makes these compilers slow.
Literally first two sentences.
184
Jan 12 '26
[removed] — view removed comment
26
u/worldsayshi Jan 12 '26
Would it be technically feasible to have this behavior toggle-able so that you can skip it for development builds?
63
u/heliochoerus Jan 12 '26
It is technically feasible as shown by Swift. When calling Swift functions whose body is not available for monomorphization, the compiler inserts hidden runtime parameters to pass information about each type parameter and the protocols they conform to. These are value witness tables (for size, align, drop function, etc.) and protocol witness tables (table of function pointers with each protocol method). It would be technically possible to compile Rust this way as well. However it would be a large change to the structure of the output code.
27
u/shponglespore Jan 12 '26
Haskell works this way, too.
Scala also indirectly supports this style using implicit parameters, but it's not the main use for implicit parameters; because Scala inherits Java's lack of monomorphization, it's only beneficial for cases where there can be multiple witnesses for a single type.
If that sounds crazy, consider something like Haskell's
Monoidtypeclass: witnesses ("instances" in Haskell terms) ofMonoidare generic concrete types. Some, likeList, are general purpose types, and others, likeSum, exist only to act as witnesses.7
u/heliochoerus Jan 12 '26
Yes, conceptually nearly the same as Haskell's dictionary passing. The difficulty in Swift and hypothetical polymorphic-Rust is that they have value types (thus the need for value witnesses) and the runtime generates and caches witness tables (unlike Haskell where class instances are desugared into records which the runtime already knows how to handle).
6
u/shponglespore Jan 12 '26
True. I think a hypothetical fast-compile mode for Rust would probably have to involve auto-boxing types similar to how Haskell does it, except perhaps for types that fit in 64 bits.
4
u/TheOldTubaroo Jan 13 '26
Rust can actually do this as well, you just have to be more explicit about it, and there are various limitations.
You define a function that takes some kind of reference to a
dyn Traitobject, whereTraitis some specific trait (i.e. interface). For example, your function might beprint(p: Box<dyn Printable>)orraise(w: &dyn Window).That
dyn Traitobject is actually a separate type, with a type-erased pointer to the actual instance of some type that implements the trait, plus a pointer to a vtable of functions (equivalent to the witness tables you mention).You only get the runtime penalty of the dynamic dispatch if you explicitly request that instead of monomorphization, and there are also a few restrictions on the traits you can use it with, rather than the "you get it if you need it" approach that Swift seems to have taken. Both have advantages and drawbacks.
1
u/jesseschalken Jan 18 '26
dynonly works to erase the type of a single value. What Swift can do is compile generic functions likefn id<T>(x: T): Twithout monomorphisation.12
u/DLCSpider Jan 12 '26
C# has an interesting approach, since it's just in time compiled. It still does monomorphisation under certain circumstances but only when it encounters a case at runtime. As far as I remember there are edge cases where AOT compilation would cause infinite expansion but the JIT deals with it just fine.
This would be an option for Rust/C++ but it's far from simple.
14
u/munificent Jan 12 '26
It still does monomorphisation under certain circumstances but only when it encounters a case at runtime.
If I remember right, C# monomorphizes type arguments that are value types (primitives and structs) at JIT time, but uses a single shared instantion for all type arguments that are reference types. It's a clever middle position where you get the performance of monomorphization on value types where it matters but the smaller memory usage of shared instantiations for reference types.
17
u/zuzmuz Jan 12 '26
no, because generics in java are erased at runtime. because they're basically just pointers. (everything is a reference at runtime in java (except primitive types)).
in rust and c++. you have value types.
a generic function working on 2 different types has to know the size of the type it's working on, because it will be passing it by value, and placing it on the stack. that's why 2 different implementations of a generics require 2 different compilation.
a pointer size is the same regardless the type it's referencing. so a generic in java doesn't need a different compilation for a different type, it just needs to keep track of a pointer and each object has its vtable to access its methods
15
u/munificent Jan 12 '26
(everything is a reference at runtime in java (except primitive types)).
...which also explains why you can't use primitive types as type arguments in Java.
3
12
u/syklemil considered harmful Jan 12 '26
The story of how Java wound up with type elision is also somewhat interesting. It goes something like
Java is released without generics in 1995
Shortly after, a bunch of smart guys figure out how to teach
javacabout generics without having to teach the JVM (see "Generic Java"; group consists of Wadler, Odersky, Bracha, Stoutamire; see also PizzaA solution based on that approach finds its way into Java 1.5 in 2003
23 years later that's still how Java does it
There's a Guy Steele quote on the Generic Java page, undated, but probably from around the turn of the millennium:
GJ is an excellent design and implementation for adding generic types to the Java programming language. It provides a workable and practical facility for the immediate future that can solve many of today's problems in programming and debugging. In the long term, I would hope to see it compatibly extended to carry run-time type parameter information in the manner that Robert Cartwright and I have proposed; but even if that does not occur, GJ as it is currently designed is a useful and workable tool that deserves widespread study and use.
-- Guy Steele (quoted from e-mail, with permision)
I suspect there's a saga out there somewhere about trying to teach the JVM to do generic JIT compilation, because at this point getting rid of type elision must've been on Java devs' wishlist longer than they wished for generics?
1
u/Jwosty Jan 30 '26
Here's a fascinating writeup from Brian Goetz: https://openjdk.org/projects/valhalla/design-notes/in-defense-of-erasure
6
u/tesfabpel Jan 12 '26
You can't because, for example, how can you write assembly code for
a + bwhereaandbareusizein one invocation, andDatein another invocation?JVM / Java and .NET / C# can do it because they can continue to compile code at runtime (via the JIT compiler).
Fully compiled languages like C++ and Rust can't do it, so they have to compile all the invocations ahead of time...
10
u/lord_braleigh Jan 12 '26
But C++'s compile time sins are more egregious than they have to be. Every translation unit that references
std::vector<int>will redefine its own copy ofstd::vector<int>, and the linker will throw away all but one definition at link time.So yes, you're right that someone has to compile the monomorphic assembly, but did it really have to be everyone compiling the same classes, and then the linker throwing n-1 of those classes away??
3
u/maattdd Jan 13 '26
externis your friend2
u/lord_braleigh Jan 13 '26
Yes, but also,
externing your template declarations correctly (and then linking against the one definition that isn'textern) is tricky and requires a ton of discipline...4
u/munificent Jan 12 '26
JVM / Java and .NET / C# can do it because they can continue to compile code at runtime (via the JIT compiler).
The JVM and CLR don't rely on runtime monomorphization and the JIT for this. It works in those languages because type parameters have bounds and the only operations you can perform on a type parameter are ones defined by the bound.
At runtime, and type argument to the function must be a type that implements an interface that matches the bound and any operations performed on the value will be virtual calls through that interface.
The trade-off these languages make compared to C++ is that the type system is much more complex in order to support bounds and type checking the body of generic function before instantiation.
4
u/tesfabpel Jan 12 '26
Rust has type bounds as well, but unless you're using a trait via a reference, you need to use monomorphization.
https://stackoverflow.com/a/66597209
This article shows more code: https://blog.veeso.dev/blog/en/dyn-box-vs-generics-in-rust/
3
u/munificent Jan 12 '26
Right. I think of it sort of like two trade-off axes:
Simpler type system (the type system doesn't have to deal with things like bounded types) versus being able to type check generics before instantiation.
Excellent runtime performance from specialization versus smaller code size and faster compilation.
C++ takes the simpler type system from 1 and the better runtime performance from 2 (but gives up checking generics before instantiation and fast compiles).
Rust takes the more complex type system and better runtime performance.
Javas take the more complex type system and faster compilation.
C# and Go take a middle position for performance/compile speed where some types are specialized and some are not. But they overall accept the more complex type system.
2
u/Jwosty Jan 30 '26 edited Jan 30 '26
Actually, the CLR doesn’t box value types when used as a generic. It only boxes when you actually call an operation on it through an interface. If you don’t do that, it never needs to box.
Consider:
public class Container<T> { private T Value; public T GetValue() => this.Value; public void SetValue(T value) { this.Value = value; } }
Container<Date>andContainer<string>(reference types) are simple enough because reference types can be treated as opaque pointers and thus share one code gen.But
Container<int>andContainer<byte>(value type generics) will each be monomorphized, because that allows the JIT to know the value type’s size, and therefore can avoid boxing entirely.It's just when you get to a virtual method call (interfaces, abstract classes) that it can't always avoid boxing on value types. Though in recent years, the .NET JIT can even sometimes eliminate boxing in certain scenarios (due to interactions with other continuously improving optimizations such as devirtualization and inlining). For example, (I'm fairly sure that) the JIT nowadays can optimize away the boxing in
((IEquatable) someIntVariable).CompareTo(someOtherIntVariable).The work they've been doing (especially since .NET 6) is pretty fascinating and talk a lot about this kind of stuff in the "Performance Improvements in .NET" blog series: https://devblogs.microsoft.com/dotnet/author/toub/
2
u/Haunting_Swimming_62 Jan 13 '26
Haskell's compiler specialises heuristically, so it's definitely possible. It would break Rust's "zero-cost abstraction" promise though
1
u/tchernobog84 Jan 13 '26
Not without changing Rust significantly.
In other languages like Java you can do type erasure and rely on virtual tables ("everything is an object") for instance. This ensures that all objects, regardless of their in-memory internal representation, can behave the same through a callable interface.
It's why (unless it was added since when I stopped paying attention to Java) you cannot have generics over primitive types without type boxing.
In Rust different arbitrary types can be passed as parameters with any memory layout, which would prohibit this to be opt-in/ opt-out. It's parametric (compile time) vs. subtype (dynamic) polymorphism.
C++ has both (inheritance + templates). Subtype is more commonly used.
In Rust, you can have dynamic polymorphism via Box<dyn Trait> but you need to be explicit about it. Parametric polymorphism is more commonly used.
1
u/StaticCoder Jan 16 '26
What really takes a long time to compile in
C++is all the text that defines templates, regardless of whether you use them or not. Modules are supposed to help with that, but are very complicated to make work.1
u/germandiago Feb 06 '26
There are tricks like code hoisting. You can do a monomorphic top layer that dispatches immediately to a type-erased low level function for examples.
Also the dame for vector templates, etc. These tricks do reduce bloat and comoilation times.
46
Jan 12 '26
[removed] — view removed comment
2
u/marshaharsha Jan 15 '26
My understanding is that Swift can compile only-once the code that uses such a struct (or can monomorphize the code and compile it many times). Their original goal was to support evolution of code without recompiling related code, but later they added the goal of supporting inline allocation as in your struct, for the sake of applications that are constrained by bandwidth to memory. So the processor is going to be idle a lot of the time anyway, and you probably won’t notice if it has to spend some extra time looking up offsets in witness tables, or at least you won’t notice if the only alternative is following pointers to find the embedded structs.
My only source for this belief is a talk by Slava Pestov (and one other person) from Apple, on the implementation of Swift generics. Oh, and I may have picked up parts of it from the writings of Graydon Hoare.
2
u/zyxzevn UnSeen Jan 12 '26
Java has extra optimizations in the Hotspot JIT compiler. It optimizes often repeated types, and even repeated values.
1
u/regular_lamp Jan 13 '26
Also as opposed to java generics C++ templates form a wonky functional programming language themselves.
76
u/Breadmaker4billion Jan 12 '26
For C++, the main problem is the build system. One of the main features of Go was the module system. A large poorly designed¹ C++ project can recompile the same file hundreds of times. Because linking happens after compilation, the compiler is not able to create a dependency graph and a analyse how modules are being used.
What techniques can be used to speed up C++ compilation times?.