Eight additions. That is the whole program, and it is enough to make the cost of position independence exact rather than rhetorical:
$ cat sum8.c
extern int a,b,c,d,e,f,g,h;
int f1(void){ return a+b+c+d+e+f+g+h; }
Build it twice and disassemble the same function. This is the whole comparison, and it is worth reading the two side by side rather than being told about it.
Non-PIC and PIC differ in one step, and everything else follows from it.
And the second cost is the one that actually shows up in a profile. A dependent load serialises: the address load must complete before the value load can issue. On a machine where the first load misses to DRAM, the second load is then issued against a just-discovered address, and it misses too. A non-PIC access is one miss; a PIC access is two serialised misses.
There is also a code-size cost, and it is the smaller of the two but it is not zero. The PIC form is 10 bytes against 6, so the extra is 4 bytes per reference — not the 1 byte you might predict from the REX prefix alone, because the second instruction is the bulk of it.
Non-PIC first, in full — nine instructions, one per global:
$ llvm-objdump-21 -d --no-show-raw-insn s_nopie.o 0000000000000000 <f1>: 0: movl (%rip), %eax # 0x6 <f1+0x6> 6: addl (%rip), %eax # 0xc c: addl (%rip), %eax # 0x12 12: addl (%rip), %eax # 0x18 18: addl (%rip), %eax # 0x1e 1e: addl (%rip), %eax # 0x24 24: addl (%rip), %eax # 0x2a 2a: addl (%rip), %eax # 0x30 30: retq
And PIC, first eight lines — note the register juggling, which is not free either:
$ llvm-objdump-21 -d --no-show-raw-insn s_pic.o 0000000000000000 <f1>: 0: movq (%rip), %rcx # 0x7 <-- address of a 7: movq (%rip), %rax # 0xe different global e: movl (%rax), %eax <-- then its value 10: addl (%rcx), %eax 12: movq (%rip), %rcx # 0x19 19: addl (%rcx), %eax 1b: movq (%rip), %rcx # 0x22 ...
Now the count, and the reason it is not simply double:
$ printf "non-pic=%s pic=%s\n" \ "$(llvm-objdump-21 -d s_nopie.o | sed -n '/<f1>/,/ret/p' | grep -cE '^ +[0-9a-f]+:')" \ "$(llvm-objdump-21 -d s_pic.o | sed -n '/<f1>/,/ret/p' | grep -cE '^ +[0-9a-f]+:')" non-pic=9 pic=17
9 against 17, not 9 against 18. The reason is visible in the listing: the compiler hoisted the address load for b above the value load for a, because the two are independent. Eight globals produce 8 address loads and 8 value loads, but the movl (%rax), %eax for the first global was folded into the addl (%rcx), %eax of the second. The overhead is one extra instruction per global, minus the cases the scheduler can fold. The asymptotic cost is still one extra instruction and one extra dependent load per reference; the constant is 1.9 rather than 2.0 for this function.
And the memory side of the ledger:
$ readelf -SW s_pic.so | grep -E ' \.got '
[21] .got PROGBITS ... 000060 08 WA
^^^^^ 0x60 = 96 bytes = 12 words
8 globals + 4 words of overhead
Twelve words of GOT for eight integers. Four of them are the reserved words the PLT needs, which the symbol-resolution course measured. The point is that the cost is not only time: a PIC binary has a data structure proportional to its number of global references, and it is writable, which means the page it lives on cannot be shared with read-only code.
Where the cost stops being negligible, and it is not where people expect. The expensive pattern is not many globals — it is globals in a hot loop, because that is where the serialised dependency chain has nothing to hide behind.
/* the pathological shape: a global read per iteration */
for (i = 0; i < n; i++) sum += counter; /* PIC: 2 dependent
loads per iteration */
/* the same loop with the value hoisted */
int c = counter;
for (i = 0; i < n; i++) sum += c; /* 2 loads, total */
The fix is not -fno-pie. It is to let the compiler hoist the load, which it will do if the source makes the loop-carried value explicit. A PIE binary that is 40% slower because of PIC is almost always a program that has asked the compiler not to optimise, not a program for which position independence is expensive.
The other case where it genuinely matters is memory footprint, and the reason is specific: the GOT is a writable page, and a writable page per process is a real cost on a system running thousands of them. A non-PIE binary has the addresses baked in and needs no writable page for them at all. On a machine with a page cache pressure problem and thousands of processes, that difference is measurable; on a desktop with two processes it is not.
And there is one place the cost is paid back, which is worth knowing because it is the reason the two costs are not independent. PIE reduces the size of the resident mapping for a PIE relative to a hypothetical fixed-layout binary of the same code, because pages shared between the executable and its children stay shared, and because the ELF header is mapped rather than reserved. This is a real effect and it is the reason some large deployments prefer PIE for footprint reasons alone — though on a machine with CONFIG_ARCH_WANTS_XPCV_QUEUE-style zero-page behaviour the effect can be null or negative. The honest answer is that this depends on the kernel, and I have not measured it here.
$ cd courses/reloc/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/F5/,/F6/p' $ python3 crosscheck.py 2>&1 | sed -n '/\[5\]/,/\[6\]/p'
Then separate the three costs, which are routinely conflated:
1. CODE SIZE. Add a 9th global. Does the gap
between 9 and 17 widen by 2 instructions or by
less? Why?
2. INSTRUCTION COUNT. Add a global that is read TWICE.
Now how many extra instructions, and why is it
not two?
3. MEMORY. What is .got for 20 globals? Subtract the
4 words of PLT overhead. Is it still 1 word per
global, or fewer?
4. DEPENDENCY. For sum8, is every PIC load dependent on
the one before it? Count the independent chains in
the listing above. (Hint: look at what %rax and
%rcx are doing.)
5. Does -fno-pie (without -fPIC) reduce ANY of these
three? Check with the crosscheck's F3 finding.
Question 5 is the one that ties the module together, and the answer is no. -fno-pie without -fPIC leaves the codegen alone, so all three costs remain. Which means -fno-pie -no-pie is the worst of both worlds: you pay the full PIC cost and get no ASLR. That configuration is a very common build-system setting and almost nobody intends it. It is the configuration the first concept of the previous module warned about, and here it is with a price tag.
This closes the PIE module, and it is deliberately the argument-completing concept rather than the most interesting one. The Flags gave the matrix and Does the Executable Actually Move? gave the benefit, measured over six runs. This one gives the price, measured to the instruction and the byte, so the decision has three quantified inputs instead of one slogan.
The mechanism connects straight back to the vocabulary module. Why There Are So Many Kinds put R_X86_64_REX_GOTPCRELX in group 3 — indirection — and noted that the REX prefix exists because the instruction was originally a call opcode. Every number on this page is the cost of that group-3 choice, and the 7-byte movq against the 6-byte movl is the REX prefix showing up in a size measurement. A format decision made in 2003 for compatibility is a byte in every reference today.
Forward into the failure module, this concept sets up the sharpest contrast in the course. PIC is not free, and it is not optional either — it is a trade, and a trade has a failure mode when you get it wrong.The Relocation That Cannot Be Fixed is what happens when the trade is refused rather than made: an object that paid for neither, containing a relocation the linker has no way to satisfy. And The One Relocation That Calls the Loader is a case where the cost is not instructions or bytes at all but a function call, which is why it needed its own concept rather than a paragraph here.
One connection outside the course, because the shape is general. Hoisting a load out of a loop is the oldest optimisation in compilers, and this concept is an argument for keeping it available even in the presence of an indirection. The same reasoning explains why const local data is faster than static in C — a const local can be put in a register, and a static cannot, because the compiler cannot prove nothing else writes it. The general rule: every indirection you add is a load the compiler has to prove is safe to move, and each such proof is a place where the source language can help or hinder. Writing const locals, and avoiding volatile where you do not mean it, is the same advice as hoisting a GOT load — and both are about giving the compiler a fact rather than asking it to discover one.