Higher level languages always win

Assembly is the lowest level language in common use. It’s translated into machine code that your computer can understand directly, leaving no abstraction between you and the machine. With no abstraction, there should be no overhead, right? And yet, almost no programmers write assembly routines. Fortran, long touted for its efficient use of computing power, is also not common anymore. And it’s not because programmers care less about performance – in the age of the cloud, billions of embedded devices, phones, and laptops, more areas than ever need better performance. But performance gains are hard to come by using such low level languages.

Take the history of Unix: before Unix, most operating systems were written in an assembly language, which made them fast on a particular machine, but nigh impossible to port. To fulfill the promise of a truly portable operating system, the researchers at Bell labs wrote C, a “high level language” at the time and rewrote Unix in it. This allowed the Unix operating system to be portable: as long as you had a C compiler that could target your machine, you could build Unix for your machine. The duty of learning optimization from C to generated assembly was left to the compiler writers, and the compiler writers and operating systems people could work on their own interests, improving both in the process. In The Unix Time-Sharing System, the authors note that the resulting Unix rewritten in C was about 1/3rd larger, but the tradeoff of using a higher level language to write the operating system was worth it for extensibility, and so, Unix stayed written in C.

Even by the 80s, most operating systems had a plethora of assembly for performance, mixing in some high level languages. By the 90s, most operating systems were written in a higher level language, like C. At some point, the amount of architectures an operating system had to support made writing assembly an arduous task – you had to write everything with nothing. You want a stack? Go use %rsp. You want a heap? Go call brk and sbrk. You want for loops? You want functions? Use labels and goto. The language itself was intimately bound to the platform, and there were too many platforms. And compilers were getting better too – they could implement more optimizations, allowing high level constructions to get fast, just as if you were writing low level code.

Even today, previously “high-level constructs” like the C-style for loop are under attack. Let’s take a look at the following program:

int main() {
    int x = 1;
    for (int i = 0; i <= 143; i++) {
        if (i < 10) {
            x += i;
        }
    }
    return x;
}

If you take a look at this program, you should notice that it can be optimized away at compile time. Basically, you just add i as it’s just 45 (the sum of 0..9) + 1, which is 46.

With x86_64 clang trunk (the version after 17.0.1), with -O3 optimizations on, the compiler generates this monstrous code:

Code generated by x86-64 clang
.LCPI0_1:
        .long   0                               # 0x0
        .long   1                               # 0x1
        .long   2                               # 0x2
        .long   3                               # 0x3
.LCPI0_2:
        .long   4                               # 0x4
        .long   4                               # 0x4
        .long   4                               # 0x4
        .long   4                               # 0x4
.LCPI0_3:
        .long   2147483648                      # 0x80000000
        .long   2147483648                      # 0x80000000
        .long   2147483648                      # 0x80000000
        .long   2147483648                      # 0x80000000
.LCPI0_4:
        .long   2147483658                      # 0x8000000a
        .long   2147483658                      # 0x8000000a
        .long   2147483658                      # 0x8000000a
        .long   2147483658                      # 0x8000000a
.LCPI0_5:
        .long   8                               # 0x8
        .long   8                               # 0x8
        .long   8                               # 0x8
        .long   8                               # 0x8
.LCPI0_6:
        .long   1                               # 0x1
main:                                   # @main
        pxor    %xmm0, %xmm0
        movd    .LCPI0_6(%rip), %xmm1           # xmm1 = [1,0,0,0]
        movdqa  .LCPI0_1(%rip), %xmm2           # xmm2 = [0,1,2,3]
        movl    $144, %eax
        movdqa  .LCPI0_2(%rip), %xmm3           # xmm3 = [4,4,4,4]
        movdqa  .LCPI0_3(%rip), %xmm4           # xmm4 = [2147483648,2147483648,2147483648,2147483648]
        movdqa  .LCPI0_4(%rip), %xmm5           # xmm5 = [2147483658,2147483658,2147483658,2147483658]
        movdqa  .LCPI0_5(%rip), %xmm6           # xmm6 = [8,8,8,8]
.LBB0_1:                                # =>This Inner Loop Header: Depth=1
        movdqa  %xmm2, %xmm7
        paddd   %xmm3, %xmm7
        movdqa  %xmm2, %xmm8
        pxor    %xmm4, %xmm8
        movdqa  %xmm5, %xmm9
        pcmpgtd %xmm8, %xmm9
        movdqa  %xmm7, %xmm8
        pxor    %xmm4, %xmm8
        movdqa  %xmm5, %xmm10
        pcmpgtd %xmm8, %xmm10
        pand    %xmm2, %xmm9
        paddd   %xmm9, %xmm1
        pand    %xmm7, %xmm10
        paddd   %xmm10, %xmm0
        paddd   %xmm6, %xmm2
        addl    $-8, %eax
        jne     .LBB0_1
        paddd   %xmm1, %xmm0
        pshufd  $238, %xmm0, %xmm1              # xmm1 = xmm0[2,3,2,3]
        paddd   %xmm0, %xmm1
        pshufd  $85, %xmm1, %xmm0               # xmm0 = xmm1[1,1,1,1]
        paddd   %xmm1, %xmm0
        movd    %xmm0, %eax
        retq

In gcc trunk (which will land in gcc-13.3, or gcc-14), it correctly optimizes away the program:

main:
        movl    $46, %eax
        ret

If you just turn the <= into an < for clang, it optimizes away the program: the compiler can now see that the if block is redundant, and the for loop can be evaluated statically, and optimizations kick in.

In rust (or other higher-level languages), you can write this code with iterators:

use std::process::exit;

pub fn main() {
    let mut x = 1;
    x += (0..=143).take_while(|i| *i < 10).sum::<u8>();
    exit(x.into())
}

And with rustc 1.75.0, with the flags -C opt-level=2, it optimizes it all away.

example::main:
        push    rax
        mov     edi, 46
        call    qword ptr [rip + std::process::exit@GOTPCREL]
        ud2

There’s a little extra ceremony since rust doesn’t allow returning from main, but if it did, it would basically be the gcc version.

The higher-level code is both clearer in intent and is easier to optimize.

But that’s not all: since iterators don’t have to specify iteration order (as long as the same result is returned), you can parallelize or iterate out of order if that speeds up code.

In C, you’d have to use an extended language like Cilk to do the same thing, whereas higher-level languages can implement these features in libraries, as C++ and Rust do. So you get readability, extendibility, and performance.

At the beginning, I mentioned that fortran and C are hard to optimize. But it’s not old languages that are hard to optimize: Lisp, for example, is not one of them. Since lisp specializes in higher-level constructs, like map, reduce, filter, and others, the language doesn’t put many restrictions on the executed low-level code. Thus, as compilers get better, lisps get better.

Dropping down to lower-level languages or using lower-level language features is always a trade-off in short-term performance for long-term maintainability and performance woes. One day, a compiler will run circles around you and you’ll be left in the dust.

That’s what ended up happening to GeOS. A fast OS written in assembly, which couldn’t be ported to enough devices to get enough market share to stick. Meanwhile, Linux, the BSDs, Windows, and Mac did.

Higher-level languages always win. The assembly programmers learned that when fortran took their programmers. The fortran programmers learned when C took bites of scientific computing. The C programmers learned about OOP when C++ came, and the C++ folks learned about the importance of memory safety when Rust came out. What’s next, I can’t say – all I can say is that the next higher level language will come, and I’ll be glad it did.