Let’s say you ask your programming language to do the simplest possible task: print out “hello world”. Generally this takes two syscalls: write and exit. The following assembly program is the ideal Linux x86_64 program for this purpose. A perfect compiler would emit this hello world program for any language.

bits 64 section .text global _start _start: mov rdx, len mov rsi, msg mov rdi, 1 mov rax, 1 syscall mov rdi, 0 mov rax, 60 syscall section .rodata msg: db "hello world", 10 len: equ $-msg

Most languages do a whole lot of other crap other than printing out “hello world”, even if that’s all you asked for.

Test case Source Execution time Total syscalls Unique syscalls Size (KiB) Assembly (x86_64) test.S 0.00s real 2 2 8.6 KiB* Zig (small) test.zig 0.00s real 2 2 10.3 KiB Zig (safe) test.zig 0.00s real 3 3 11.3 KiB C (musl, static) test.c 0.00s real 5 5 95.9 KiB C (musl, dynamic) test.c 0.00s real 15 9 602 KiB C (glibc, static*) test.c 0.00s real 11 9 2295 KiB C (glibc, dynamic) test.c 0.00s real 65 13 2309 KiB Rust test.rs 0.00s real 123 21 244 KiB Crystal (static) test.cr 0.00s real 144 23 935 KiB Go (static w/o cgo) test.go 0.00s real 152 17 1661 KiB D (dmd) test.d 0.00s real 152 20 5542 KiB D (ldc) test.d 0.00s real 181 21 10305 KiB Crystal (dynamic) test.cr 0.00s real 183 25 2601 KiB Go (w/cgo) test.go 0.00s real 211 22 3937 KiB Perl test.pl 0.00s real 255 25 5640 KiB Java Test.java 0.07s real 226 26 15743 KiB Node.js test.js 0.04s real 673 40 36000 KiB Python 3 (PyPy) test.py 0.68s real 884 32 9909 KiB Julia test.jl 0.12s real 913 41 344563 KiB Python 3 (CPython) test.py 0.02s real 1200 33 15184 KiB Ruby test.rb 0.04s real 1401 38 1283 KiB

* See notes for this test case

This table is sorted so that the number of syscalls goes up, because I reckon more syscalls is a decent metric for how much shit is happening that you didn’t ask for (i.e. write("hello world

"); exit(0) ). Languages with a JIT fare much worse on this than compiled languages, but I have deliberately chosen not to account for this.

These numbers are real. This is more complexity that someone has to debug, more time your users are sitting there waiting for your program, less disk space available for files which actually matter to the user.

Environment

Tests were conducted on January 3rd, 2020.

gcc 9.2.0

glibc 2.30

musl libc 1.1.24

Linux 5.4.7 (Arch Linux)

Linux 4.19.87 (vanilla, Alpine Linux) is used for musl libc tests

Go 1.13.5

Rustc 1.40.0

Zig 0.5.0

OpenJDK 11.0.5 JRE

Crystal 0.31.1

NodeJS 13.5.0

Julia 1.3.1

Python 3.8.1

PyPy 7.3.0

Ruby 2.6.4p114 (2019-10-01 rev 67812)

dmd 1:2.089.0

ldc 2:1.18.0

Perl 5.30.1

For each language, I tried to write the program which would give the most generous scores without raising eyebrows at a code review. The size of all files which must be present at runtime (interpreters, stdlib, libraries, loader, etc) are included. Binaries were stripped where appropriate.

This was not an objective test, this is just an approximation that I hope will encourage readers to be more aware of the consequences of their abstractions, and their exponential growth as more layers are added.

test.S

bits 64 section .text global _start _start: mov rdx, len mov rsi, msg mov rdi, 1 mov rax, 1 syscall mov rdi, 0 mov rax, 60 syscall section .rodata msg: db "hello world", 10 len: equ $-msg

nasm -f elf64 test.S gcc -o test -static -nostartfiles -nostdlib -nodefaultlibs strip test: 8.6 KiB

Notes

This program only works on x86_64 Linux.

The size depends on how you measure it:

Instructions + data alone: 52 bytes

Stripped ELF: 8.6 KiB

Manually minified ELF: 142 bytes

test.zig

const std = @import("std"); pub fn main() !void { const stdout = try std.io.getStdOut(); try stdout.write("hello world

"); }

# small zig build-exe test.zig --release-small --strip # safe zig build-exe test.zig --release-safe --strip

Notes

Written with the assistance of Andrew Kelly (maintainer of Zig)

test.c

int puts(const char *s); int main(int argc, char *argv[]) { puts("hello world"); return 0; }

# dynamic gcc -O2 -o test test.c strip test # static gcc -O2 -o test -static test.c strip test

Notes

glibc programs can never truly be statically linked. The size reflects this.

test.rs

fn main() { println!("hello world"); }

rustc -C opt-levels=s test.rs

Notes

The final binary is dynamically linked with glibc, which is included in the size.

test.go

package main import "os" func main() { os.Stdout.Write([]byte("hello world

")) }

# dynamic go build -o test test.go # static w/o cgo GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o test -ldflags '-extldflags "-f no-PIC -static"' -buildmode pie -tags 'osusergo netgo static_build' test.go

Aside: it is getting way too goddamn difficult to build static Go binaries.

Notes

The statically linked test was run on Alpine Linux with musl libc. It doesn’t link to libc in theory, but hey.

Test.java

public class Test { public static void main(String[] args) { System.out.println("hello world"); } }

javac Test.java java Test

test.cr

puts "hello world

"

# Dynamic crystal build -o test test.cr # Static crystal build --static -o test test.cr

Notes

The Crystal tests were run on Alpine Linux with musl libc.

test.js

console.log("hello world");

node test.js

test.jl

println("hello world")

julia test.jl

Notes

Julia numbers were provided by a third party

test.py

print("hello world")

# cpython python3 test.py # pypy pypy3 test.py

test.pl

print "hello world

"

perl test.pl

Notes

Passing /dev/urandom into perl is equally likely to print “hello world”

test.d

import std.stdio; void main() { writeln("hello world"); }

# dmd dmd -O test.d # ldc ldc -O test.d

test.rb

puts "hello world

"