How big is "Hello, world?"

Rust

I accidentally got an extra day off from work, so then I figured I could have a go at some initial dabblings in Rust. I haven’t had time to play with this language at all yet, but skimming through the tutorial online a while ago got me interested. So off I went and wrote the mandatory intro-program: “Hello, world!”

fn main() {
    println!("Hello, world!");
}

Nice and easy, and running the rust compiler gives me an executable I can run right away:

$ rustc hello.rs
$ ./hello
Hello, world!
$ ls -l
total 428
-rwxr-xr-x 1 rabalder rabalder 430656 Mar 19 13:27 hello
-rw-r--r-- 1 rabalder rabalder     45 Mar 19 10:18 hello.rs

Whoa! What’s that, a minimalist hello world program takes about 430kB? Sounds excessive, but to be frank I didn’t have anything to compare with, so let’s see how some other common languages fare.

C++

C++ is mature and has a long history. Together with it’s parent C and grandparent FORTRAN, this is pretty much where the art of compiler writing has been developed and fine tuned. I would expect this to fare really well, so let’s see how it checks out:

#include <iostream>
int main()
{
    std::cout << "Hello, world!" << std::endl;
}

Compiling and checking with both gcc and clang:

$ g++ -o hello-g++ hello.cpp
$ clang++ -o hello-clang++ hello.cpp
$ ./hello-g++
Hello, world!
$ ./hello-clang++
Hello, world!
$ ls -l
total 28
-rwxr-xr-x 1 rabalder rabalder 8416 Mar 19 13:34 hello-clang++
-rw-r--r-- 1 rabalder rabalder   83 Mar 19 10:23 hello.cpp
-rwxr-xr-x 1 rabalder rabalder 8424 Mar 19 13:34 hello-g++

Pretty much exactly the same size for both. Definitely a lot better. Still a tad large for such a minimalist program, I think.

D

Another language I have found interresting for quite a while is D. I have never really had the time to play with it though. At least nothing beyond the simple hello world type of programs. However reading about the language design, and following its development, I think it looks very promising.

import std.stdio;
void main()
{
    writeln("Hello, world!");
}

Here we have three choices for the compiler, the official dmd, ldc using llvm as a backend, and gdc built on the Gnu Compiler Collection:

$ dmd -ofhello-dmd hello.d
$ ldc2 -ofhello-ldc2 hello.d
$ gdc -o hello-gdc hello.d
$ ./hello-dmd
Hello, world!
$ ./hello-ldc2
Hello, world!
$ ./hello-gdc
Hello, world!
$ ls -l
total 3172
-rw-r--r-- 1 rabalder rabalder      65 Mar 19 13:39 hello.d
-rwxr-xr-x 1 rabalder rabalder  533080 Mar 19 14:39 hello-dmd
-rwxr-xr-x 1 rabalder rabalder 2689880 Mar 19 15:23 hello-gdc
-rwxr-xr-x 1 rabalder rabalder   12712 Mar 19 14:08 hello-ldc2

Interesting!

gdc produces an absolutely humongous executable. 2.7MB for a simple hello world is clearly out of line!

On the other hand dmd produces an executable in line with our original samle in Rust, 530kB. The winner is clearly ldc which gets away with 13kB. That’s almost in line with the C++ version, even if it’s about twice as large.

The object files may be more interesting in this case:

-rw-r--r-- 1 rabalder rabalder   39512 Mar 19 15:31 hello-dmd.o
-rw-r--r-- 1 rabalder rabalder   21936 Mar 19 15:29 hello-gdc.o
-rw-r--r-- 1 rabalder rabalder   12384 Mar 19 15:30 hello-ldc2.o

They are much more comparable. The dmd-produced object file is still three times as large as the one produced by ldc, but it’s less than a tenth of the complete executable. Also gdc plots itself nicely in the middle.

My suspicion is that the rest of the executable in the gdc and dmd case is the full runtime library. On the other hand ldc produces an executable only slightly larger than the object file, which indicates it only links in the parts of the runtime that it needs. Why the huge difference between the executables produced by gdc and dmd, I do not know. If the object files was any indication, I would expect the relationship to be the other way around.

Assembler

Just for comparison I also did a take a small hello world in assembler:

                use64

stdout          equ     1
syscall_write   equ     1
syscall_exit    equ     60

section .data
hello           db  'Hello, world!', 10
hello_len       equ $-hello

section .text
                global _start

_start:
                mov rax, syscall_write
                mov rdi, stdout
                mov rsi, hello
                mov rdx, hello_len
                syscall

                mov rax, syscall_exit
                mov rdi, 0
                syscall

I used nasm to assemble this:

$ nasm -f elf64 -o hello.o hello.asm
$ ld -o hello hello.o
$ ./hello
Hello, world!
$ ls -l
total 12
-rwxr-xr-x 1 rabalder rabalder 1064 Mar 19 14:47 hello
-rw-r--r-- 1 rabalder rabalder  491 Mar 19 12:40 hello.asm
-rw-r--r-- 1 rabalder rabalder  992 Mar 19 14:46 hello.o

Here we end up with an executable of about 1kB. My first attempt was actually about half that size again. I accidentally stripped away unused symbols that the linker includes by default. However, since I’ve done no optimizations in the other cases I found that to be a bit unfair. Still this is quite minimal compared to the other examples.

Conclusion

So why do the different languages and compilers produce so wildly different executables for the same program?

For the really big deviations we see with Rust and D compiled with gdc or dmd I am certain that the result is caused by including the full runtime library in the executable. There’s probably ways to strip out what’s not being used, which would reduce the size considerable for all of these cases. I have not explored how to do this.

Looking at the object files produced from the actual source files seems to be a lot more predictable across languages and compilers. While the executable is meant to be a complete and standalone entity, something you can just run. The object file represents the actual machine code produced directly from the source. This means that the executable must include any supporting code that is needed during runtime. This part can vary significantly from one language to another.

In fact, producing the object file from Rust, we see the following:

$ rustc --emit obj hello.rs
$ ls -l hello.o
-rw-r--r-- 1 rabalder rabalder   3696 Mar 19 14:56 hello.o

The object file is only 3.7kB. That’s one third of the code produced by ldc again. Also for C++ the object files differ significantly from the finished executable:

-rw-r--r-- 1 rabalder rabalder   2656 Mar 19 16:05 hello-g++.o
-rw-r--r-- 1 rabalder rabalder   2856 Mar 19 16:08 hello-clang++.o

With this comparison, it seems the Rust compiler is quite comparable to the code produced by the much more mature and state of the art compilers for C++.

Anyways, this is a detour, and not an attempt to compare which language or compiler is better in any way. This is far from any real world scenario, and for more complex programs you may get completely different results.

I have specifically not done any optimization of the linker stage. I wanted to see the difference between how the different languages compilers produced the resulting executable with the default settings.

Further, comparing a very young language like Rust, that’s still very much evolving, to some of the most mature toolchains there is will never be a fair comparison. Even if D is quite a bit older, it is also a language that still evolves rapidly. For both of these languages, I expect that as both the languages and tools mature they will become more comparable both to C++ and to each other.

Even with all of the caveats above, I found this exercise quite interesting. I hope you did too.