1
$\begingroup$

A friend and I were surprised that C still has near-best performance among languages. I thought about why this is, and I wrote up a few paragraphs. I wonder if the friendly folks on CS stack could tell me where I'm wrong, too vague, or misleading. This will help me sharpen my understanding of the situation.

One reason it’s hard to improve on C in the current context is that current OS’s force everything to “pass through” something like C anyway.

When you write code in another language and execute it, the C-like thing it passes through need not be the C language itself (though pretty often it literally is, if some part of the language is implemented in C). But rather it’s the set of low-level primitives exposed by the OS (“system calls”). There aren’t terribly many of these (on the order of hundreds), and C is the modern language that is closest to them. In fact, it’s the only language(?) that actually contains the raw system calls inside the language.

Using the notation “more abstract layer —(API)—> less abstract layer”, you could write a simple diagram “your code —(system calls) —> the OS —(instruction set) —> the hardware”. Think of C as living pretty far to the right, toward the system calls. In fact it actually contains some (all?) of the system calls in the language itself, which I believe is unique among languages. So some parts of C are more like an API than they are like a language, and “API’s ossify”.

You could ask, why not more system calls, each more specialized and optimized? Several hundred isn’t that many, in the face of the multitude of things computers can do. That question is probably deeper than I can answer, but my take is that all things being equal, a smaller API is a better API, because small API’s reduce combinatorial explosion.

As your code is executed, you can understand it in stages, like a ladder, where up/down corresponds to more/less abstract. (It’s really a DAG, not a ladder, and maybe it need not even be strictly acyclic always, but whatever. Let’s pretend no branching for now.) Each rung in the ladder communicates with the rung below it through an API, that is, a little language which the lower rung created so you can tell it what you want it to do. You could even say that the rung is just some useful combinations of the API beneath it, packaged into an API for some higher rungs to use.

Breaking things up into layers/stages/rungs is beneficial for understanding and reuse. First and fairly obviously, if you can factor out the function of one rung, you can reuse that rung, and it becomes worth it to devote significant effort to optimizing that rung. But second and maybe less obvious, if your program is in layers which communicate through limited-size APIs, it helps reduce combinatorial explosion in the space of how programs can operate. Another way to say this is with layers, there are fewer ways that things can go wrong at any layer. This is more true when the API’s on each layer are kept smaller. And third and maybe most important, the smaller an API exposed by a rung, the less you constrain the operation of the rung, so that the rung can change how it works and adapt to new situations, but still be used exactly as before.

But breaking things up like this has costs. You’re always sacrificing efficiency in each individual case when you constrain your architecture like that. Also, the stability of APIs is a double-edged sword: nothing ossifies like an API. Because their benefit is that they’re reliable and stable, so many things come to rely on them, so no one wants to change them even if they’re long outmoded, because at any given moment no one wants to break everything. Hence the arcane commands at the terminal (bash language), and the relative stability of the set of system calls.

$\endgroup$
6
  • 2
    $\begingroup$ Your argument strays away from, "Why is C fast and performant?" and into a discussion of why stable APIs are good, the benefits of abstraction, and a brief mention of system calls. Instead, consider focusing more on how C's history, design, and implementations lead to its "everlasting" near-best performance. $\endgroup$ Feb 13, 2022 at 16:52
  • 1
    $\begingroup$ See these "Is C fast?" answers on Quora. $\endgroup$ Feb 13, 2022 at 16:58
  • $\begingroup$ System calls... they are quite irrelevant to performance in my experience. If they are relevant, then the OS finds ways to avoid them. $\endgroup$
    – gnasher729
    Feb 15, 2022 at 18:15
  • $\begingroup$ Your interpretation of how a program works, through a hierarchy of system calls, is somewhat irrelevant. Performance matters for compute-intensive programs, for which the C optimizing compilers are unbeatable. When it comes to program mostly leaning on system calls, no optimization can take place an all languages run at the same speed ! $\endgroup$
    – user16034
    Mar 15, 2022 at 19:48
  • $\begingroup$ As regards Python, this is not a compiled language so it cannot compete. $\endgroup$
    – user16034
    Mar 15, 2022 at 19:50

4 Answers 4

4
$\begingroup$

C is fastest because there is nothing preventing it from being the fastest.

A CPU always runs machine language. When writing a "fast" program, your goal is to give the CPU the fastest machine language to accomplish the task.

In C, it is usually possible to write something that translates into fast machine language - although you cannot write self-modifying code, or self-threaded code, for example. In other languages, you often have a lot less flexibility and the language forces you to have slower machine code.

For example, you may want to use SIMD instructions to process multiple pieces of data at the same time. In C, there's usually a compiler-specific extension which allows you to write these instructions - even if it's less convenient. In Java or C#, you simply can't use them because there's no way to use them.

Many languages insist on writing a bounds check every time you access an array. Many languages insist that you cannot access just half of a variable.

The history of C comes from people wanting to write "portable assembly code", or assembly code in a slightly nicer format. C has evolved in a direction that mostly allows you to do whatever your processor's machine code can do, and trusts you to use it responsibly. Most other languages focus on making it easier to write simple and reliable programs, and forbid you from doing things that are usually wrong. Not C.

TL;DR: C is flexible and doesn't stop you from doing things, therefore it doesn't stop you from writing fast code.

In case the fastest solution to your problem does involve self-modifying code or some other pattern which can't be expressed in C, you may find that assembly code is faster than C.

$\endgroup$
1
  • $\begingroup$ Generally C was designed so that each operator matched a single machine code instruction (e.g. ++x and y--), while array references were very simple calculations (x[3] and 3[x] produce load address of x into an address register; add 3 to it; and reference through that register). All type information is used at compile time only. I think even the ternary operator (c?a:b) was a single PDP machine instruction. $\endgroup$ Nov 4, 2022 at 3:21
4
$\begingroup$

The OS has virtually nothing to do with the relative performance of languages on benchmarks. You wrote

“your code —(system calls) —> the OS —(instruction set) —> the hardware”

suggesting you think that the OS acts as a sort of translation layer between user-mode code and the CPU, but in reality the OS just puts the CPU in the appropriate mode and jumps to the user code, which then runs directly on the hardware until it makes a blocking system call or it's preempted by a hardware interrupt. What runs directly on the hardware may be a bytecode interpreter, but if you were using the same language implementation without an OS, you would be using the same or a very similar bytecode interpreter. It doesn't matter what language the OS is written in, or whether an OS is present at all.

System calls usually aren't benchmarked in comparative language shootouts, so I have no hard data, but there should be no overhead in the system-call interface that is peculiar to system calls. When CPython computes x+y, it has to check the types of the heap objects x and y, extract the "raw" values from them, and create a new heap object to hold the result of the addition. The same steps are needed when you call an OS function that takes two numeric arguments and returns a numeric result. Data buffers (for file I/O and such) may be copied to and from the heap but don't have to be. Even CPython, which is a quite slow interpreter, supports raw I/O without copying.

The benchmark that you linked can't be trusted because it shows C++ as substantially slower than C. I don't know what they're benchmarking, but whatever it is, they could have taken their C benchmark code and compiled it as C++ with at most minor changes, and gotten the exact same performance. They chose instead to write less efficient C++ code, probably because they are biased toward thinking that C++ is a higher-level language in which you should write more maintainable code, while C is a low-level language in which you should write performance-maximizing spaghetti code. The numbers in the table reflect their biases and not the true abilities of the languages. That or the programmers they used for the different languages aren't equally skilled. Either way, all of the numbers are untrustworthy.

$\endgroup$
2
  • 1
    $\begingroup$ "The OS has virtually nothing to do with the relative performance of languages on benchmarks." – OSs can make it easier or harder to create performant implementations for certain styles. For example, iOS does not allow JIT compilation. This makes it hard to create performant implementations of programming languages with a high degree of dynamic polymorphism (like practically all OO languages in the Smalltalk tradition). Also, OSs which use virtual memory (practically all current mainstream OSs) can hurt garbage collection performance, which makes it harder to implement languages with … $\endgroup$ Feb 18, 2022 at 16:18
  • 1
    $\begingroup$ … Lisp-style automatic memory management compared to e.g. Swift-style or Rust-style AMM (or no AMM as in C). $\endgroup$ Feb 18, 2022 at 16:18
1
$\begingroup$

If one defines a "task" as a mapping between possible inputs a program might receive, and the set of possible outputs that would be acceptable for each such input, different languages will be able to process different tasks more or less efficiently. This will be especially true if one recognizes a task X as being a more specific version of Y if, for all possible inputs for which Y defines a set of possible outputs, the set of acceptable outputs for X is a subset of the set specified for Y.

In many cases, the only way of writing a program in a language that processes a broadly-specified task will be to write a program that processes a more specific version of that task, and the most efficient possible machine code for a program to process the specific version will often be less efficient than the most efficient program to process the original broadly-specified one. The efficiency of a language for processing some tasks will often depend to some degree to which programs written in that language would need to process needlessly specific versions of those tasks.

If one is evaluating how efficiently languages can process programs to perform tasks that can be precisely specified in language L, language L will be able to process such tasks precisely, without losing efficiency to over-precise specification. Consider the following three specifications for a function:

  1. Given two 64-bit signed integers x and y, compute the bottom 64 bits of the mathematical product of x and 30, interpret them as a 64-bit signed integer, divide that by y using truncate-toward-zero division, and return the result.

  2. Given two 64-bit signed integers x and y, return x*y/30 if x is less than 300,000,000,000,000,000 and y is non-zero, and otherwise return any number.

  3. Given two 64-bit signed integers x and y, return x*y/30 if x is less than 300,000,000,000,000,000 and y is non-zero, and otherwise behave in any fashion whatsoever, without regard for ordinary rules about time and causality.

Each of the first two tasks is a more specific version of the one following it. A Java implementation, given the expression x*30/y would seek to generate the most efficient machine code to accomplish the first task. Even if the compiler could determine that y would always be 30, it would be obligated to generate code that performs multiplication, truncation, and division precisely as specified. A C implementation given that expression, however, would be allowed to generate the most efficient machine code for the third task. In situations where a compiler could prove that y would be 30, this could be vastly more efficient than the optimal code a Java implementation could produce for its task.

If one compares languages based upon how well they can handle tasks like #3 above, C will come out ahead. On the flip side, however, if one were to instead compare languages based upon how well they could handle tasks like #1 above, that advantage would disappear since any C code satisfying the tasks would have to specify the same operations as Java code. The advantage would also likely disappear for "standard C" if programs that perform task #2 above would be satisfactory, but those performing only task #3 would not, but would be available to C dialects which specify integer overflow behavior more precisely than the C Standard, but less precisely than Java.

$\endgroup$
0
0
$\begingroup$

It isn't really hard to understand what is a system call. On the x86-64 architecture, the high level code (like C or C++) is compiled to syscall in assembly. The assembler then converts the syscall instruction into its binary format that the processor understands. The syscall instruction makes the processor jump to the address found in the STAR64 register, a special register of the processor.

With current paging, x86-64 processors have 2 levels of protection: user and supervisor. This is set with flags in the page tables. When in user mode, the processor cannot execute privileged instructions and it cannot access supervisor mode pages. What it can do is execute syscall which makes it jump to a specific address in supervisor mode. At that specific address, the kernel will place an handler to handle system calls. Each operating-system is specific in what it will do in the handler. For example, Linux will look at RAX for the system call number but Windows will look at another register.

Once you understand this, I think your question becomes clear. C and C++ are most often the fastest because they compile directly to syscall. Meanwhile, languages like Python or Java are slower because they compile to intermediate bytecode or are all the way interpreted from code (like I think it is the case for Python). Java is compiled to bytecode that is interpreted by a JVM (Java Virtual Machine) which is itself written in C/C++ and compiled to direct system calls.

It is certain that most APIs on most operating-systems are written in C or C++. But things don't "pass through C". Things are compiled to binary which doesn't care about the high level language you are using. You could be using another language than C or C++ and it could be faster once compiled. It could be higher level and take more time to compile but still be faster once compiled.

C doesn't contain the system calls. C isn't much broader than most other languages out there. The difference is that it is compiled before execution so its hardware accesses can be compiled to a bunch of syscall instructions with some movs to put the right arguments into the registers.

The fact that the os API is written in C/C++ doesn't make the language more broad. For example, on Windows, the API is a set of DLLs. The header associated with a DLL can be included in a C/C++ program that will allow the use of the functions within the DLL. The DLL is then linked against and the linker resolves that, to reach a certain function, the program needs to jump at that certain address. It is mostly done just before runtime by the dynamic linker. The function itself (in the API), is doing syscalls and it is already compiled but the symbols of the functions are kept along with their positions within the code.

This doesn't augment the language itself but it simply uses the current implementation of the language to its full potential by exposing some complex APIs to the language. I think it could not do that with Java or Python because they are most often not compiled. It makes it more complex to expose low level APIs to a language that isn't compiled and that doesn't understand things like linking and system calls.

I don't think the fact there isn't much more system calls with time has much to do with the API. The API is a set of functions the os exposes to drive hardware or do some special stuff that aren't included in the language by default. The syscalls in the meantime are used by the API functions to call the kernel to do some work. API and syscall isn't the same. The syscall numbers don't need to move much because there isn't the need to add much more of them.

This is often understood once you start to get a grasp of the virtual-filesystem present on most modern operating-systems. The virtual-filesystem will present most devices as files to the upper layer of the os. To drive most devices, the only thing you need is read, write and the general ioctl call. You don't need one system call per device. This isn't a matter of reducing the amount of possible combinations. It is a matter of supporting all devices by exposing one common system call that will include support for all devices of a certain type.

The API in itself, is simply there so that the compiler knows what numbers in what registers should be paired with what syscall to have a certain effect on the computer.

$\endgroup$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.