Hacker Timesnew | past | comments | ask | show | jobs | submitlogin

Static linked binaries are a generally lot smaller than a dynamically linked library and its dependencies, especially with link-time optimizations and inlining.

You wouldn't want have 100 tools statically link the entirety of chromium, but for normal C library sizes you don't get bloat. The preference for dynamic libraries in Linux distros is just so they can roll out patch updates in one place instead of rebuilding dependents.



But dynamically linked library only needs to be loaded to RAM once whereas with static linking you'd be loading the same code many times (unless you compile everything to single binary like BusyBox). This also gets you better cache utilization.

Also I think inlining would typically increase the total size of output rather than decrease it.


Static linking gives you better instruction cache utilization as you are executing local code linearly rather than going through indirection with more boilerplate. This indirection costs a few cycles too.

Inlining external code reduces the size not only by saving the call, PLT and and stack dance, but also through specialization (removal of unused conditional, pruning of no longer referenced symbols) as the code is locally optimized. This further reduction in size further improves cache behavior and performance.

Duplication can be an issue (not necessarily for performance, but for total binary size), but compilers have heuristics for that. Even just having the symbol local saves some space and call overhead though (no PLT).

The case for the shared library having better caching implies multiple processes that are distinct executables (otherwise they share program memory regardless of linkage) trying to hammer it at once, sharing the continued caching, but such scenario is hurt by the call overhead and lower optimization opportunities, negating the result.


> Static linking gives you better instruction cache utilization as you are executing local code linearly rather than going through indirection with more boilerplate.

No, it does not, it worsens it.

For example, «strlen», if it comes from a dynamic library, will be loaded into the physical memory once and only once, and it will be mapped into each process's address space as many times as there are processes. Since «strlen» is a very frequently used function, there is a very high chance that the page will remain resident in memory for a very long time, and since the physical page is resident in memory, there is also a very good chance that the page will remain resident at least in the L2 cache, but – depending on circumstances – in the L1 cache, too. A TLB flush might not even be necessary in specific circumstances, which is a big performance win. It is a 1:N scenario.

With the static linking, on the other hand, if there are 10k processes in the system, there will be 10k distinct pages containing «strlen» loaded into memory at 10k random addresses. It is a M:N scenario. Since the physical memory pages are now distinct, the context switching will nearly always require the TLB to be flushed out which is costly or very costly, and more frequent L1/L2 cache invalidations due to «strlen» now residing at 10k distinct physical memory addresses.

P.S. I am aware that C compilers now inline «strlen» so there is no actual function call, but let's pretend that it is not inlined for the sake of the conversation.


The compiler has built-ins for parts of libc exactly because dynamic linkage is ridiculous for performance, but they cannot statically link with a dynamic libc. It's a hack to make dynamic linked libc have at least somewhat acceptable performance.

If your libc was statically linked, you would not need the built-in - the strlen impl from your libc would get inlined by LTO.

The chances of a particular routine being in L1 is absolutely miniscule - it's hard enough to keep a single process and it's data in L1 and L2. What might happen is that you find it in L3, but: 1. The code you're loading is now much larger (fitting less well in L1 so you'll get more L1 misses) and slower (cache aside, it has redirection overhead and has not been LTO'd for this use), and 2. The inlined version would probably also be found in L3 - either resident or prefetched as that section if the process executable obviously had to be loaded to switch to it. 3. Unless the system is idle, the cache will be trashed in between process switches by the loads from other processes.

So while you could technically have a case where the shared lib is in cache, I do not think a realistic scenario exists where that setup wins out. There are more distinct pages, but the pages didn't fit in the first place: by having each process access fewer pages overall it can miss less while it is running.


> The compiler has built-ins for parts of libc exactly because dynamic linkage is ridiculous for performance […]

The argument is entirely contrived and has no root in facts. Compiler built-ins appeared in GNU C/C++ compilers as an attempt to replace the non-portable inline assembly with portable primitives – across compilers and across different architectures as well. The rationale is well documented in the GNU C/C++ compiler v2.3 circa documentation, and it has nothing to do with the dynamic linking.

The use of the compiler built-ins increased once the C/C++ compilers gained the interprocedural, in-file, holistic optimisation capabilities – to improve the quality of the generated code. Moreover, compiler built-ins had been present in some form even in the 32-bit Watcom C compiler for MS-DOS and MS-DOS had no shared libraries or whatsoever.

> The chances of a particular routine being in L1 is absolutely miniscule - it's hard enough to keep a single process and it's data in L1 and L2 […]

CPU caches work at addresses being accessed level, not at the process level. The CPU knows nothing about processes – the CPU is a code interpreter.

One copy of «strlen» in a single memory page at a single physical memory address shared across all processes has a much better chance of staying in the cache for a longer time as opposed to 10k copies of the same «strlen» implementation in 10k memory pages strewn across 10k distinct physical addresses. A single page that is accessed frequently has a higher hit rate and, thus, fewer chance of getting evicted from the cache – these are the basics one can't go against. CPU's other than Intel CPU's have larger or large I-caches, too, therefore very frequently used code has higher chances of survival in the CPU cache. Most importantly, however, the CPU cache (L1/L2) size is not the bottleneck, the TLB size is – a frequently accessed address is better from the TLB perspective than 10k distinct addresses as it will result in a fewer number of the TLB flushes.

Lastly, the shared library cache I was referring to has nothing to do with the CPU execution time. It is the cache where the shared libraries are «pre-linked» to reduce the startup, the GOT fixup and the dynamic library initialisation times – to improve the user experience, not performance.


> The argument is entirely contrived and has no root in facts. Compiler built-ins appeared in GNU C/C++ compilers as an attempt to replace the non-portable inline assembly with portable primitives

This is missing the point entirely.

GCC needs to emit e.g. memory copies. Before, this was inline assembly replicated over and over. Now, it's a call to __builtin_memcpy.

The point missed is that GCC always considered the idea of calling memcpy entirely unacceptable as the performance would be horrible over an inline implementation.

The proof of this intent lies in later optimizations: Not only would GCC never want to emit such slow calls, it replaces your explicit libc calls with builtins because obviously you wouldn't want to do something as slow as a dynamic linkeage call.

With static linking and LTO, the libc implementation becomes as good as the builtin, rendering the latter pointless. GCC just cannot assume this to be the case.

> CPU caches work at addresses being accessed level, not at the process level.

No, CPU caches do not work on addresses, they work on tags to be pedantic. Either way, I never said that caches are process level. I said that they do not survive across multiple processes - not because of flushing, but because of trashing. I.e., if you have three processes, A, B and C, where A and C run shared code while B something else, and you switch A -> kernel -> B -> kernel -> C, then by the time you made it form A to C your cache is has been trashed by both B and the kernel.

Now, instead of 3 processes and one routine, make it thousands of threads and gigabytes of shared libraries.

> One copy of «strlen» in a single memory page at a single physical memory address shared across all processes

Again, strlen is a terrible example: 10k copies of strlen being a handful of bytes in the current instruction stream, prefetched and branch predicted will outperform that shared page to an outright ridiculous extent and might even be smaller in total: a 10k copies of a handful of bytes vs. 10k calls and PLT indirections + the un-inlined function. Because it is literally less memory, it also trashes the TLB less.

Even in more realistic cases, remember the TLB hit of the PLT table in each application, not to mention the many more pages consumed by the bulkier implementation. In fact, let's focus a bit on the TLB. The most basic Gtk app links at least 80 libraries worth over 90 megabytes on my system. An L1 TLB has about 64 entries, the L2 around a thousand or so - so it can reference ~16MB worth of memory or thereabout. In other words, even the L2 TLb is about 6 times too small to keep the libraries of the simplest possible gtk app cached.

Heck, take just libicudata at 30MB. Of course, I wouldn't suggest statically linking that, but just pointing out that a single dependency of a Gtk app is enough to fill up the TLB twice, nullifying the idea of any cache benefit to using these libraries.

"Yes but at least they can have libicudata in L3!" - yeah, no - not only would it compete with other dynamic dependencies (for this and other processes), but more importantly the applications also need to process data. A single Gtk app on a 4k monitor will, for example, be managing at least two 32MB framebuffers (3840x2160x4, x2 for double buffering), so that's most of your cache gone during draw before you even consider the input to the draw or any actual functionality of the app!

The best-case for dynamic linkage performance is cases where call cost is irrelevant, e.g. when calling compute routines. There is no point whatsoever in considering CPU caches outside the scope of the currently running process.


> The case for the shared library having better caching implies multiple processes that are distinct executables

But this is the most common case for desktops/multipurpose systems.

On my desktop there are tens or hundreds distinct processes sharing most of their code.


No it is not.

Depending on your CPU, you might have, say, 32KB of 8-way associative instruction cache per core. Just being shared does not make it fit in the cache.

A shared library would only be there across processes of different executable images if its users primarily, continuously execute the same paths in shared libs rather than anything unique in their own executable image - e.g., they'd more or less need to be stuck in the same processing-intensive shared routine in the lib. There would also have to be no other processing done in between by other processes that would have trashed the cache.

On the other hand, the severe cache penalty of longer code paths for each executable and the larger PLT call overhead will universally lead to a loss in performance for all library usage.

The scenarios you may hit where different processes are actually executing the same shared code paths to the point of benefiting from shared cache utilization would be cases where they share executable image as well. E.g., browser processes, threads, compilers. Electron too if using system-packaged electron binaries.


> A shared library would only be there across processes of different executable images if its users primarily, continuously execute the same paths in shared libs rather than anything unique in their own executable image

Yes, like libc/WinApi, Cairo/whatever graphics library of your OS, Gtk/Qt etc

All of the apps on my desktop (I don't use electron) share vast majority of their code (see above) and spend most of their time drawing UI through shared libs or doing their own business logic but that's part of their code and not subject to shared lib overhead.


It's really important to emphasize how small caches are (especially as even the KBs they have cannot always be fully utilized). When we talk about processor caches, we're trying to make the current routine of one process fit well* - not even the whole thing.

No, you don't have a desktop environment where a majority of your unique executables all spend time at the same time in the same small, compute-heavy libc routine with no other processes to trash the cache in between.

For Gtk for example, the applications are not taking the same inputs and drawing the same GUI at the same time, with no other processes in between to wipe the cache.

Instead they're primarily running their own application logic in their own time, and interaction with Gtk (accepting input, rendering) is on timescales so long that the cache has been wiped out over and over in between (16ms is practically infinity at these scales). In these cases, the cache will be filled at the time of execution with e.g. that apps runtime data, widget tree and what not.

At the same time, remember that a static routine is much smaller and faster. Even if way say you fetch a shared library routine from cache and save some cycles there, every single call to it incurs large performance hits over the static linkage: a few cycles to every call from the PLT, possibly many cycles from poorer optimization (e.g., branches you never need), and cycles from not fitting as well in the tiny instruction cache as the process executes it.

... And if that shared lib routine calls other shared libs, then you get to apply the overheads and lost optimization recursively. This recursion where the static linkage pruning is especially effective: you might have tens or hundreds of megabytes of dependencies dynamically, but single megabyte static output as unused functionality is pruned.

There is no realistic scenario where dynamic linkage wins on performance or cacheability - it just doesn't play well with how caches end up used. Overall system memory utilization can be slightly lower for dynamic linkage in some cases, but not in a notable way.


I'll give you a scenario where dynamic linkage is a clear win.

I have 16GB of RAM and usually when working all of it is in use. If everything was compiled statically I would get massive swapping.


So you only need to load duplicated code for each different statically linked program. If there are many processes running the same program, they will all share the same physical pages for the code. So for example, having 100s of "bash" instances running does not use that much memory.

You can see this by running "pmap <pid> -XX" (the output is very wide- probably load it into an editor). Look at the shared vs. private pages.

Also: There is another way to automatically share pages between different programs: de-duplication. This would require common libraries to be statically linked on page boundaries. The OS would quickly de-duplicate during loading by hashing the pages. VMs use this technique to increase effective memory when there are many guest OS running.


Yes but most processes are unique. The only bash process I have running is my interactive shell.




Consider applying for YC's Fall 2026 batch! Applications are open till July 27.

Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: