TLS Models and Access
A thread-local variable has no fixed address, so the compiler cannot simply emit
mov var, %eaxthe way it would for a global. Instead it must compute this thread’s copy of the variable at runtime, relative to the thread pointer (TP) — the%fssegment base on x86-64,TPIDR_EL0on AArch64. How much work that computation takes depends on how much is known at link time: is the variable in the main executable (offset fixed forever), in a library loaded at startup (offset fixed after relocation), or in a module that might bedlopened later (nothing known)? The ABI defines four TLS access models — local-exec, initial-exec, local-dynamic, and general-dynamic — trading generality for speed, each with its own instruction sequence and relocations. The canonical reference is Ulrich Drepper’s ELF Handling For Thread-Local Storage; the revision read for this note is Version 0.21, dated August 22, 2013, and it states on its own title page that it is based on the Intel Itanium processor-specific ABI (May 2001, document 245370-003) and Sun’s Thread-Local Storage, Version 0.68 (September 4, 2001) (Drepper 2013). That matters because the filetls.pdfcirculates in several revisions under the same name — quote a section number and a version, not just the filename. This note explains the four models and the newer TLS-descriptor (TLSDESC) scheme; the storage they address is Thread-Local Storage and the control block the TP points at is The Thread Control Block.
Every instruction sequence, relocation name, link error, dlopen failure, and timing figure below was produced on the machine this note was written on rather than copied from a reference: GCC 16.2.1 (Red Hat 16.2.1-2), GNU ld 2.46.1 (Fedora 44), glibc 2.43, on an AMD Ryzen AI MAX+ 395 (Zen 5, 16 cores / 32 threads). Where the measured behaviour contradicts the documentation — and in one important case it does, because Fedora configures GCC with --with-tls=gnu2 while the GCC manual still calls gnu “the conservative default” — the measurement is reported and the contradiction named.
Mental Model: a ladder from cheapest to most general
Think of the four models as a ladder. At the bottom, local-exec is a single instruction — the variable’s offset from the TP is a compile-time/link-time constant. Climbing up, each rung buys more flexibility (the variable can be in a shared library, or in a library not yet loaded) at the cost of more instructions, a Global Offset Table entry, or a function call. The compiler picks the most restrictive model that is still correct for a given variable and translation unit; the linker can further relax a general sequence down the ladder when it discovers the variable is actually closer than the compiler assumed.
flowchart TB LE["local-exec — cheapest<br/>var defined in main executable<br/>offset from TP = link-time constant<br/>1 instruction: mov %fs:x@tpoff"] IE["initial-exec<br/>var in exe or a startup DSO<br/>offset fixed after relocation<br/>load offset from GOT, add TP"] LD["local-dynamic<br/>≥1 local vars in a dlopen-able module<br/>one __tls_get_addr for module base,<br/>then add link-time offsets"] GD["general-dynamic — most general<br/>var anywhere, module unknown<br/>call __tls_get_addr(module, offset)"] LE --> IE --> LD --> GD GD -.->|"linker relaxation<br/>(only ever downward,<br/>only when provable)"| LE note["compiler emits the most restrictive model it can prove correct;<br/>the linker may relax a higher model down to a lower one;<br/>TLSDESC re-encodes the top two rungs so the choice<br/>between static and dynamic is made at LOAD time instead"]
The four TLS access models, ordered by cost. What it shows: each rung relaxes an assumption — local-exec assumes the variable is in the executable, initial-exec assumes it is in a module present at startup, the dynamic models assume nothing and pay for a runtime resolver call. The insight to take: the model is a promise about where the variable lives, and the cost is proportional to how little the toolchain can assume; -fPIC code defaults to general-dynamic because a shared object cannot assume it is the main executable.
What Your Toolchain Actually Defaults To — and the Grep Trap
Before any of the four models can be demonstrated, one property of the local toolchain has to be pinned down, because getting it wrong invalidates every experiment that follows. There are two independent knobs, and they are routinely confused:
-ftls-model=picks the access model —global-dynamic,local-dynamic,initial-exec,local-exec. This is a target-independent code-generation option.-mtls-dialect=picks, on x86, the encoding used to implement the two dynamic models —gnu(the classic__tls_get_addrcall) orgnu2(TLS descriptors). It has no effect at all on initial-exec or local-exec, which never call a resolver.
GCC’s own documentation gives defaults for both. For the model: “The default without -fpic is ‘initial-exec’; with -fpic the default is ‘global-dynamic’” (GCC, Code Generation Options). For the dialect: “‘gnu’ is the conservative default; ‘gnu2’ is more efficient, but it may add compile- and run-time requirements that cannot be satisfied on all systems” (GCC, x86 Options).
The second of those is false on this machine, and on every stock Fedora machine:
$ gcc -Q --help=target | grep -iE 'tls'
-mstack-protector-guard= tls
-mtls-dialect= gnu2
-mtls-direct-seg-refs [enabled]
$ gcc -v 2>&1 | tr ' ' '\n' | grep -i tls
--with-tls=gnu2
$ gcc -Q --help=common | grep ftls-model
-ftls-model=[global-dynamic|local-dynamic|initial-exec|local-exec] global-gcc -Q --help=<class> prints the value each option currently holds, not the value the manual documents, which is exactly why it is the right tool here: Fedora builds GCC with --with-tls=gnu2, so the compiler’s built-in default dialect is gnu2 even though upstream’s prose still says gnu. The manual is describing the upstream default; the distribution overrode it at configure time. Always ask the compiler, never the manual, and never your memory.
flowchart TB SRC["__thread int x;<br/>a reference to x in C"] M{"-ftls-model<br/>(which model?)"} D{"-mtls-dialect<br/>(how is a dynamic<br/>model encoded?)"} SRC --> M M -->|"local-exec"| LE["%fs:x@tpoff<br/>R_X86_64_TPOFF32<br/>dialect irrelevant"] M -->|"initial-exec"| IE["GOT load + %fs:(%reg)<br/>R_X86_64_GOTTPOFF<br/>dialect irrelevant"] M -->|"local-dynamic<br/>general-dynamic"| D D -->|"gnu"| GNU["call __tls_get_addr@plt<br/>R_X86_64_TLSGD / TLSLD<br/>symbol __tls_get_addr IS referenced"] D -->|"gnu2 (Fedora default)"| GNU2["call *(%rax) via descriptor<br/>R_X86_64_GOTPC32_TLSDESC<br/>+ R_X86_64_TLSDESC_CALL<br/>symbol __tls_get_addr is NEVER referenced"]
How the two flags compose. What it shows: -ftls-model selects the rung of the ladder; -mtls-dialect only matters once you are on one of the two dynamic rungs, where it chooses between a real function call and a descriptor call. The insight to take: because the dialect defaults to gnu2 here, the “general-dynamic” code this toolchain emits by default contains no reference to __tls_get_addr whatsoever — so the usual detection heuristic silently reports the wrong answer.
That consequence deserves to be stated as a measurement rather than an assertion. Compiling the same source with each dialect and inspecting the resulting shared objects:
$ gcc -O2 -fPIC -ftls-model=global-dynamic -mtls-dialect=gnu -shared -o relax_gnu.so relax.c
$ gcc -O2 -fPIC -ftls-model=global-dynamic -mtls-dialect=gnu2 -shared -o relax_gnu2.so relax.c
$ nm -D --undefined-only relax_gnu.so
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
U __cxa_finalize@GLIBC_2.2.5
w __gmon_start__
U __tls_get_addr@GLIBC_2.3 <-- present
$ nm -D --undefined-only relax_gnu2.so
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
U __cxa_finalize@GLIBC_2.2.5
w __gmon_start__ <-- __tls_get_addr absent entirely
$ readelf -Wr relax_gnu.so | grep -E 'DTP|TLSDESC'
0000000000002fc0 0000000600000010 R_X86_64_DTPMOD64 0000000000000000 v + 0
0000000000002fc8 0000000600000011 R_X86_64_DTPOFF64 0000000000000000 v + 0
$ readelf -Wr relax_gnu2.so | grep -E 'DTP|TLSDESC'
0000000000003010 0000000500000024 R_X86_64_TLSDESC 0000000000000000 v + 0Both objects use dynamic TLS for the same preemptible variable v. Grepping for __tls_get_addr finds it in one and misses it in the other. Two side observations fall out of the same output and are worth keeping: first, the classic gnu dialect emits two dynamic relocations per general-dynamic reference (an adjacent DTPMOD64/DTPOFF64 pair, which the psABI requires to be adjacent — “such relocations are always in pairs, such that the R_X86_64_DTPOFF64 relocation applies to the word64 right past the corresponding R_X86_64_DTPMOD64 relocation”, x86-64 psABI, object-files.tex), whereas gnu2 emits one. Second, TLSDESC leaves a positive fingerprint of its own in the dynamic section:
$ readelf -d relax_gnu2.so | grep -i tlsdesc
0x000000006ffffef6 (TLSDESC_PLT) 0x320
0x000000006ffffef7 (TLSDESC_GOT) 0x2fe0So the correct way to answer “does this object use dynamic TLS?” is to look at relocations and dynamic tags, not at symbol names:
| Question | Wrong check | Right check |
|---|---|---|
| Does this object use dynamic TLS at all? | nm -D | grep __tls_get_addr | readelf -Wr for DTPMOD64, DTPOFF64, or TLSDESC |
| Which dialect was it built with? | guess from the GCC manual | DT_TLSDESC_PLT/DT_TLSDESC_GOT present ⇒ gnu2; U __tls_get_addr ⇒ gnu |
| Which model did the compiler pick? | -ftls-model on the command line | objdump -dr the object file and read the relocation names |
| Which model survived linking? | the object file | objdump -d the linked output — the linker relaxes |
The last row is not pedantry. The relocation in a .o records what the compiler asked for; the instructions in the linked .so or executable record what the linker decided, and those routinely differ (see the Linker Relaxation, Measured section below).
The Thread Pointer and the Two Variants
Every model bottoms out in the thread pointer, a per-thread register base that points at The Thread Control Block. Its access is architecture-specific and deliberately low-level. On x86-64 the TP is the base of the %fs segment; “the content of the word addressed via %fs:0 is the address of the very same location” (Drepper 2013 §3.4.6) — i.e. %fs:0 is a self-pointer, letting code load the TP into a register with a single mov %fs:0, %rax, or reference TLS directly as %fs:offset without loading the TP at all. On AArch64 the TP is the TPIDR_EL0 system register. On i386 it is %gs.
Layout comes in two variants (Drepper 2013 §3, MaskRay 2021):
- Variant I places the static TLS blocks after (above) the thread pointer, with the TCB at the low end. Used by “arm, aarch64, alpha, ia64, m68k, mips, ppc, and riscv.”
- Variant II places the static TLS blocks before (below) the TP, with the TP pointing at the start of the TCB. Used by “i386, x86-64, s390, and sparc.”
The x86-64 psABI states the same rule independently and tersely: “The %fs segment register is used to implement the thread pointer. The linear address of the thread pointer is stored at offset 0 relative to the %fs segment register”, followed by the canonical one-liner movq %fs:0, %rax (x86-64 psABI, object-files.tex). Note what is not being said: %fs:0 does not hold “some pointer”, it holds the address of itself. The self-pointer exists because x86-64 offers no instruction to read a segment base directly from user code (RDFSBASE was added much later, and only with the FSGSBASE CPU feature), so the ABI reserved the first word of the TCB to hold a copy of the base, and the only way to materialise the thread pointer in a general register is to load through the segment.
The practical consequence on x86-64 (Variant II): a static-model TLS variable sits at a negative offset from the TP, so the local-exec offset (@tpoff) is a negative number and the access is %fs:x@tpoff. This is why the offset formulas for Variant II architectures subtract from the thread-register value while Variant I architectures add. The negativity is directly visible in a disassembly; here is a __thread int defined in a main executable, four bytes below the thread pointer:
$ objdump -d --no-show-raw-insn main_pie | sed -n '/<read_exe>:/,/ret/p'
00000000004004d0 <read_exe>:
4004d0: mov %fs:0xfffffffffffffffc,%eax # -4, sign-extended
4004d8: ret0xfffffffffffffffc is objdump printing the 32-bit immediate -4 after sign extension. A reader who expects TLS offsets to be small positive numbers will read that as a wild address; on a Variant II architecture it is the first variable in static TLS.
The Test Case Used Throughout
Every disassembly below comes from this one file, which deliberately contains both a preemptible thread-local (extern-visible, so another module could interpose a definition) and two module-local ones (static, so the compiler knows nothing outside can redefine them). That distinction turns out to matter more than the command-line flag:
/* tlsmod.c */
__thread int tls_pub; /* extern-visible: preemptible */
static __thread int tls_loc1; /* module-local, non-preemptible */
static __thread int tls_loc2; /* module-local, non-preemptible */
int read_pub(void) { return tls_pub; }
int read_loc(void) { return tls_loc1 + tls_loc2; }
void bump(void) { tls_loc1++; tls_loc2 += 2; tls_pub++; }Compiled as gcc -O2 -fPIC -c -ftls-model=<model> -mtls-dialect=<dialect> tlsmod.c, and inspected with objdump -dr on the object file so the compiler’s requested relocations are still visible. bump() exists only to keep the module-local variables live; without a write, GCC constant-folds read_loc() to xor %eax,%eax and the local-dynamic sequence never appears at all. (That is itself a trap worth knowing: a “TLS model demo” that reads but never writes a static __thread variable will show you nothing, because the optimiser proved the answer is zero.)
General-Dynamic: the default, the most general
General-dynamic makes no assumptions: neither the module ID nor the variable’s offset is known at link time. “Compilers will by default generate code with this model” (Drepper 2013 §4.1). The compiler emits a call to the runtime resolver __tls_get_addr, passing a pointer to a tls_index structure — a pair { ti_module, ti_offset } — that the dynamic linker fills in via relocations.
On x86-64 the sequence is exactly 16 bytes, and the padding is not accidental: the data16 and rex64 prefixes are semantic no-ops chosen to make the sequence long enough that the linker can overwrite it in place with a shorter model’s code (Drepper 2013 §4.1.6). Here it is as actually emitted, with relocations:
$ gcc -O2 -fPIC -c -ftls-model=global-dynamic -mtls-dialect=gnu tlsmod.c
$ objdump -dr --no-show-raw-insn tlsmod.o | sed -n '/<read_pub>:/,/^$/p'
0000000000000000 <read_pub>:
0: sub $0x8,%rsp
4: data16 lea 0x0(%rip),%rdi # c <read_pub+0xc>
8: R_X86_64_TLSGD tls_pub-0x4
c: data16 data16 rex.W call 14 <read_pub+0x14>
10: R_X86_64_PLT32 __tls_get_addr-0x4
14: mov (%rax),%eax
16: add $0x8,%rsp
1a: retRead it instruction by instruction. data16 lea x@tlsgd(%rip), %rdi computes, into the first argument register, the address of a two-slot GOT entry; the R_X86_64_TLSGD relocation is what tells the linker to allocate that entry. call __tls_get_addr@plt invokes the runtime resolver. mov (%rax),%eax finally loads the variable — note that %rax holds an absolute address, not an offset, so no %fs: prefix appears. The sub $0x8,%rsp / add $0x8,%rsp pair is pure calling-convention overhead: a leaf function that would otherwise need no stack frame now needs the stack 16-byte aligned because it makes a call.
The x@tlsgd(%rip) expression makes the linker allocate a two-slot tls_index in the GOT: GOT[n] gets the dynamic relocation R_X86_64_DTPMOD64 (the module ID, resolved at load time) and GOT[n+1] gets R_X86_64_DTPOFF64 (the offset within that module’s TLS block). The psABI spells out what each resolves to: DTPMOD64 “resolves to the index of the dynamic thread vector entry that points to the base address of the TLS block corresponding to the module that defines the referenced symbol”, and DTPOFF64/DTPOFF32 “compute the offset from the pointer in that entry to the referenced symbol” (x86-64 psABI). __tls_get_addr reads those and returns dtv[module] + offset (the dynamic thread vector itself is Thread-Local Storage’s subject). This is the slow path in two senses: it is a real function call, and — because __tls_get_addr must preserve every register it does not clobber — “the __tls_get_addr calling convention penalty” is real (MaskRay 2021). But it works for any variable in any module, including one dlopened after the reference was compiled, and it permits deferred allocation — __tls_get_addr allocates the block on first touch.
A detail that surprises people reading this sequence for the first time: R_X86_64_TLSGD is applied to a lea, not to a mov. The resolver is passed the address of the tls_index pair, not its contents, because it needs both words and the calling convention gives it one register.
Local-Dynamic: amortizing the resolver call
Local-dynamic is the optimization for the common case of several thread-locals defined in the same (possibly dlopen-able) module. The insight: the offsets of module-local variables are link-time constants relative to the module’s block; only the module’s base address for this thread is unknown. So call __tls_get_addr once to get the module base, then reach each variable with a plain add of a constant offset (Drepper 2013 §4.2):
$ objdump -dr --no-show-raw-insn tlsmod.o | sed -n '/<read_loc>:/,/^$/p'
0000000000000020 <read_loc>:
20: sub $0x8,%rsp
24: lea 0x0(%rip),%rdi # 2b <read_loc+0xb>
27: R_X86_64_TLSLD tls_loc1-0x4
2b: call 30 <read_loc+0x10>
2c: R_X86_64_PLT32 __tls_get_addr-0x4
30: mov 0x0(%rax),%edx
32: R_X86_64_DTPOFF32 tls_loc1
36: add 0x0(%rax),%edx
38: R_X86_64_DTPOFF32 tls_loc2
3c: add $0x8,%rsp
40: mov %edx,%eax
42: retOne call, then two ordinary memory operands whose displacements are filled in by R_X86_64_DTPOFF32 relocations resolved entirely at link time. Only one GOT slot is needed (GOT[n] with R_X86_64_DTPMOD64); the DTPOFF64 slot is unnecessary because the offsets travel as immediates in the instruction stream instead. “For every additional variable only three new instructions have to be added and no additional GOT entries or run-time relocations” (Drepper 2013 §4.2.6) — so with two or more module-local variables in play, local-dynamic beats calling general-dynamic per variable. The psABI describes the same trick from the linker’s side: TLSGD and TLSLD “both resolve to PC-relative offsets to a DTPMOD GOT entry. The difference … is that, for R_X86_64_TLSGD, the following GOT entry will contain the offset of the referenced symbol into its TLS block, whereas, for R_X86_64_TLSLD, the following GOT entry will contain the offset for the base address of the TLS block” (x86-64 psABI).
-ftls-model=global-dynamicdoes not mean "use general-dynamic everywhere".The disassembly above was produced with
-ftls-model=**global-dynamic**, notlocal-dynamic— and GCC still emittedR_X86_64_TLSLD. Compiling the identical file with-ftls-model=local-dynamicproduces byte-identical code forread_loc(). The flag is a ceiling, not a mandate: GCC’s documentation says plainly that “the choice is subject to optimization: the compiler may use a more efficient model for symbols not visible outside of the translation unit, or if-fpicis not given on the command line” (GCC, Code Generation Options).tls_loc1andtls_loc2arestatic, therefore non-preemptible, therefore eligible for the cheaper model, and GCC takes it without being asked.The practical reading: you can ask for a more general model than the compiler would have chosen and get it, but asking for general-dynamic will not stop the compiler from quietly demoting local symbols to local-dynamic, nor the linker from demoting further. To force the general sequence for a study, use an extern-visible symbol as
read_pub()does.
There is a sharp edge on the other side of that flag, too. Forcing -ftls-model=local-dynamic on a translation unit containing the preemptible tls_pub makes GCC emit R_X86_64_TLSLD + R_X86_64_DTPOFF32 tls_pub for it as well — that is, it takes the promise at face value and generates code that assumes tls_pub lives in this module’s TLS block. If another module interposes a definition of tls_pub, that assumption is wrong and the access silently reads the wrong storage. -ftls-model is an assertion the programmer makes about linkage, and the compiler does not check it.
Initial-Exec: static offset, no function call
Initial-exec is the fast static model for variables “known to be in one of the modules available at program start” (Drepper 2013 §4.3) — the executable itself or a shared object mapped before main, but not a dlopened one. Because all such modules are allocated contiguously at thread creation (static TLS), every variable in them has a fixed offset from the TP, computable by the dynamic linker once at load time. The generated code has no __tls_get_addr call at all: it loads that offset from a GOT slot and adds the TP.
Drepper’s canonical form loads the thread pointer and adds the offset (Drepper 2013 §4.3.6):
movq %fs:0, %rax # load thread pointer (self-pointer)
addq x@gottpoff(%rip), %rax # R_X86_64_GOTTPOFF → add offset from GOT; %rax = &xbut what GCC 16 actually emits for a plain read is the shorter variant that never materialises the thread pointer at all, folding the segment override into the addressing mode:
$ gcc -O2 -fPIC -c -ftls-model=initial-exec tlsmod.c
$ objdump -dr --no-show-raw-insn tlsmod.o | sed -n '/<read_pub>:/,/^$/p'
0000000000000000 <read_pub>:
0: mov 0x0(%rip),%rax # 7 <read_pub+0x7>
3: R_X86_64_GOTTPOFF tls_pub-0x4
7: mov %fs:(%rax),%eax
a: retTwo instructions, one GOT load and one segment-relative load, no stack frame, no call, and — crucially — %rax now holds an offset rather than an address, which is why %fs: reappears (MaskRay 2021). The x@gottpoff(%rip) reference makes the linker allocate a GOT entry carrying R_X86_64_TPOFF64, “processed at program startup time by the dynamic linker by looking up the symbol x… The offset is written in the GOT entry and later loaded” (Drepper 2013 §4.3.6). The payoff versus general-dynamic: “the number of run-time relocations is cut by half” (one GOT entry instead of the module/offset pair) and there is no function call. The cost is generality — an initial-exec variable’s module must be present at startup, which is exactly the constraint behind the dlopen “cannot allocate memory in static TLS block” failures measured below.
Initial-exec is also the one model where the dialect flag provably does nothing: -mtls-dialect=gnu and -mtls-dialect=gnu2 produce byte-identical objects, because there is no resolver call to encode either way. The same is true of local-exec. Only the two dynamic models have a dialect.
Modern x86 has quietly complicated the encoding side of this. The psABI now defines three additional relocations that mean the same thing as R_X86_64_GOTTPOFF but describe differently-sized instruction prefixes — R_X86_64_CODE_4_GOTTPOFF (44), R_X86_64_CODE_5_GOTTPOFF (47) and R_X86_64_CODE_6_GOTTPOFF (50), distinguished by whether “the instruction starts at 4 / 5 / 6 bytes before the relocation offset”, with the original GOTTPOFF covering the 3-bytes-plus-REX case; the spec then warns that “linker optimization must take the different instruction encoding into account” (x86-64 psABI). A matching quartet exists for TLSDESC (R_X86_64_CODE_4/5/6_GOTPC32_TLSDESC, 45/48/51). They exist because the distance from the start of an instruction to its relocated displacement is no longer fixed: longer prefixes push the displacement further out, and a relaxing linker that assumed a fixed distance would rewrite the wrong bytes. The spec’s own parallel treatment of the non-TLS GOTPCRELX family names the mechanism concretely — it gates that optimisation on “the first byte of the instruction at the relocation offset − 4 is 0xd5” (x86-64 psABI, linker-optimization.tex), and 0xd5 is the REX2 prefix introduced by Intel’s APX extension. Nothing in this note’s disassembly uses these relocations (this toolchain targets baseline x86-64), but a reader who meets R_X86_64_CODE_4_GOTTPOFF in a newer object should read it as initial-exec, not as something new.
Uncertain
Verify: that the
CODE_4/5/6_GOTTPOFFandCODE_4/5/6_GOTPC32_TLSDESCrelocations were added specifically for APX (REX2/EVEX-prefixed) instruction encodings. Reason: the psABI text states only the byte distance for the TLS variants and names0xd5(theREX2prefix) only in theGOTPCRELXlinker-optimization section; the APX attribution for the TLS quartet is inferred from that parallel, not stated. To resolve: read the binutils/GCC commits that introduced these relocation numbers, or the APX ABI supplement. uncertain
Local-Exec: one instruction
Local-exec is the fastest and least general: the variable is defined in the main executable itself. The executable is always module 1, always at the base of static TLS, so a local-exec variable’s offset from the TP is a link-time constant — no GOT, no relocation processed at runtime beyond the linker filling in the immediate. On x86-64 (MaskRay 2021, and the IA-32 analog in Drepper 2013 §4.4.2):
$ gcc -O2 -fPIC -c -ftls-model=local-exec tlsmod.c
$ objdump -dr --no-show-raw-insn tlsmod.o | sed -n '/<read_pub>:/,/^$/p'
0000000000000000 <read_pub>:
0: mov %fs:0x0,%eax
4: R_X86_64_TPOFF32 tls_pub
8: retOne instruction. x@tpoff is a negative immediate (Variant II), and the load/store is a single segment-relative memory access with no GOT, no call, and no runtime relocation. To compute the address rather than load the value: movq %fs:0, %rax; leaq x@tpoff(%rax), %rax. This is the theoretical floor for TLS cost, and it is what you get for any __thread variable defined in the main executable.
The interesting part of local-exec is what happens when the promise is false. Asking for it in a shared object fails at link time, not at run time, and the error message is worth reading closely:
$ gcc -O2 -fPIC -shared -ftls-model=local-exec -o lib.so tlsmod.c
/usr/bin/ld.bfd: /tmp/ccOGXYe6.o: relocation R_X86_64_TPOFF32 against symbol `tls_pub'
can not be used when making a shared object; local-exec is incompatible with -shared
/usr/bin/ld.bfd: failed to set dynamic section sizes: bad value
collect2: error: ld returned 1 exit statusR_X86_64_TPOFF32 encodes an offset from the thread pointer as a link-time constant in the instruction stream. For a shared object there is no such constant — the module’s position in static TLS is not known until load time, and may not be in static TLS at all. The linker therefore has no legal value to write and refuses. (The trailing clause “local-exec is incompatible with -shared” is a comparatively recent addition to GNU ld’s diagnostic; older binutils emit only the first half, which is why the error is widely remembered as cryptic. This is binutils 2.46.1.)
The four models on x86-64, side by side. The “indirections” column counts memory dereferences on the path from the instruction stream to the variable’s address, which is the honest proxy for cost:
| local-exec | initial-exec | local-dynamic | general-dynamic | |
|---|---|---|---|---|
| Promise made | var is in the main executable | var’s module is loaded at startup | var is in this module (possibly dlopened) | nothing |
| Compiler picks it when | not -fPIC, symbol defined locally (-ftls-model default without -fpic is initial-exec, further relaxed) | not -fPIC, or symbol is extern but the toolchain may assume startup presence | -fPIC and the symbol is static/hidden — chosen automatically even under -ftls-model=global-dynamic | -fPIC and the symbol is preemptible |
| Compile-time relocs | R_X86_64_TPOFF32 | R_X86_64_GOTTPOFF | R_X86_64_TLSLD + one R_X86_64_DTPOFF32 per variable | R_X86_64_TLSGD |
| Run-time (dynamic) relocs | none | 1 · R_X86_64_TPOFF64 in the GOT | 1 · R_X86_64_DTPMOD64 per module | 2 · DTPMOD64 + DTPOFF64 per symbol |
| Indirections to the value | 0 (immediate offset) | 1 (GOT load) | 2 (GOT load inside the resolver, then DTV walk) | 2, repeated per symbol |
| Resolver call | none | none | one per module, amortised over every module-local variable reached from it | one per symbol reference the compiler cannot merge |
Works in a dlopened module? | no — link error | only while static-TLS surplus lasts | yes | yes |
| Deferred (on-first-touch) allocation? | no | no | yes | yes |
| Measured cost, hot loop (this box) | ~1.0 core cycle | ~1.0 core cycle | ~5.2 cycles (gnu) / ~1.0 (gnu2) | ~5.2 cycles (gnu) / ~1.0 (gnu2) |
Drawn as the actual chain of dereferences each model walks from the instruction stream to the storage:
flowchart LR subgraph LE["local-exec"] direction LR LE1["instruction<br/>immediate = −4"] --> LE2["%fs base + (−4)"] --> LEV[("the variable")] end subgraph IE["initial-exec"] direction LR IE1["instruction<br/>RIP-rel to GOT"] --> IE2["GOT slot<br/>(TPOFF64, filled by ld.so<br/>once at startup)"] --> IE3["%fs base + offset"] --> IEV[("the variable")] end subgraph LD["local-dynamic"] direction LR LD1["instruction<br/>RIP-rel to GOT"] --> LD2["GOT slot<br/>(DTPMOD64)"] --> LD3["__tls_get_addr<br/>-> module base"] --> LD4["+ DTPOFF32 immediate<br/>(no further loads,<br/>one per variable)"] --> LDV[("the variable")] end subgraph GD["general-dynamic"] direction LR GD1["instruction<br/>RIP-rel to GOT"] --> GD2["GOT pair<br/>(DTPMOD64, DTPOFF64)"] --> GD3["__tls_get_addr"] --> GD4["DTV[mod] + off<br/>(allocating if needed)"] --> GDV[("the variable")] end
The four access paths as dereference chains. What it shows: each model adds exactly one more level of “look it up somewhere” than the one above — an immediate, then a GOT slot, then a resolver call, then a DTV walk inside that call. The insight to take: the local-dynamic chain looks as long as general-dynamic’s but only its first access pays for the call; every additional variable branches off at the + DTPOFF32 node with no extra loads, which is precisely why the model exists.
The measured column is explained and its methodology given in the Measured Cost of Each Model section below; the short version is that the gnu-dialect numbers are dominated not by the resolver’s own work but by the fact that the compiler cannot hoist the call out of a loop.
Choosing and Forcing a Model
Three things choose the model, in this order, and each can overrule the guess you formed from the one before it.
1 — The compiler, from linkage and -fpic. GCC’s documented rule is exact: “The default without -fpic is ‘initial-exec’; with -fpic the default is ‘global-dynamic’”, qualified immediately by “the choice is subject to optimization: the compiler may use a more efficient model for symbols not visible outside of the translation unit” (GCC, Code Generation Options). In practice: a -fPIC translation unit gets general-dynamic for preemptible symbols and local-dynamic for static/hidden ones; a non-PIC translation unit gets initial-exec for extern symbols and local-exec for its own definitions. Verified on the executable side of the test case, where read_exe() reads a __thread variable defined in the program and read_dso() reads one defined in a library:
$ gcc -O2 -c main.c && objdump -dr --no-show-raw-insn main.o
0000000000000000 <read_exe>:
0: mov %fs:0x0,%eax
4: R_X86_64_TPOFF32 exe_var <-- local-exec
8: ret
0000000000000010 <read_dso>:
10: mov 0x0(%rip),%rax
13: R_X86_64_GOTTPOFF tls_pub-0x4 <-- initial-exec
17: mov %fs:(%rax),%eax
1a: ret2 — You, with -ftls-model= or an attribute. The flag’s four values name the models directly (local-exec, initial-exec, local-dynamic, global-dynamic); per-variable control is __attribute__((tls_model("initial-exec"))). Remember from the local-dynamic section that this is a ceiling with an override, not a mandate: the compiler may still demote a non-preemptible symbol below what you asked for, and it will obey you when you ask for a model that is unsound for the symbol’s linkage.
3 — The linker, by relaxation. Whatever survives into the object file may be rewritten at link time when the linker learns something the compiler could not know. That is a large enough effect to demonstrate rather than assert; see the next section.
Forcing initial-exec on a library’s hot thread-locals is a well-known micro-optimisation — but it silently makes that library un-dlopenable once the static-TLS surplus is exhausted, which is measured concretely in the When the Model Makes dlopen Fail section below. It is a trade, not a free win.
Linker Relaxation, Measured
The compiler emits code for a shared object without knowing whether the object file will end up in a shared object. If the same -fPIC object is linked into an executable, several of the compiler’s assumptions become false in the program’s favour — the executable is module 1, it is at the base of static TLS, and its own symbols cannot be preempted — so the linker rewrites the sequence in place. The over-long prefixes in the general-dynamic encoding exist precisely to leave room for this.
Take a minimal file, force general-dynamic with -fPIC, and then link it two ways:
/* relax.c */
__thread int v;
int get(void) { return v; }
int main(void) { return get(); }Classic gnu dialect. Object file, then the same object linked into a PIE executable, then into a shared object:
$ gcc -O2 -fPIC -ftls-model=global-dynamic -mtls-dialect=gnu -c -o relax_gnu.o relax.c
0000000000000000 <get>: # the object file
0: sub $0x8,%rsp
4: data16 lea 0x0(%rip),%rdi
8: R_X86_64_TLSGD v-0x4
c: data16 data16 rex.W call 14 <get+0x14>
10: R_X86_64_PLT32 __tls_get_addr-0x4
14: mov (%rax),%eax
16: add $0x8,%rsp
1a: ret
$ gcc -O2 -o relax_gnu relax_gnu.o # linked into an executable
00000000004004b0 <get>:
4004b0: sub $0x8,%rsp
4004b4: mov %fs:0x0,%rax <-- relaxed to LOCAL-EXEC
4004bd: lea -0x4(%rax),%rax
4004c4: mov (%rax),%eax
4004c6: add $0x8,%rsp
4004ca: ret
$ gcc -shared -o relax_gnu.so relax_gnu.o # linked into a shared object
0000000000000400 <get>:
400: sub $0x8,%rsp
404: data16 lea 0x2bb4(%rip),%rdi
40c: data16 data16 rex.W call 300 <__tls_get_addr@plt> <-- NOT relaxed
414: mov (%rax),%eax
416: add $0x8,%rsp
41a: retThe 16-byte general-dynamic sequence became movq %fs:0,%rax + leaq -0x4(%rax),%rax — a load of the thread pointer and a constant subtract — with the padding absorbed. Against the shared object it was left alone, because v is preemptible there and nothing about its final location is known at link time. Notice also the residue of relaxation: the sub $0x8,%rsp / add $0x8,%rsp stack alignment remains in the relaxed executable even though no call survives. The linker rewrites instructions, not the function’s frame; relaxation removes the call, not its overhead.
TLSDESC gnu2 dialect, same experiment:
$ gcc -O2 -fPIC -ftls-model=global-dynamic -mtls-dialect=gnu2 -c -o relax_gnu2.o relax.c
0000000000000000 <get>: # the object file
4: lea 0x0(%rip),%rax
7: R_X86_64_GOTPC32_TLSDESC v-0x4
b: call *(%rax)
b: R_X86_64_TLSDESC_CALL v
d: mov %fs:(%rax),%eax
$ gcc -O2 -o relax_gnu2 relax_gnu2.o # linked into an executable
0000000000400490 <get>:
400490: sub $0x8,%rsp
400494: mov $0xfffffffffffffffc,%rax <-- the TP offset as an IMMEDIATE
40049b: xchg %ax,%ax <-- 2-byte nop, leftover padding
40049d: mov %fs:(%rax),%eax
4004a0: add $0x8,%rsp
4004a4: retHere the relaxation is even more direct: the whole descriptor mechanism collapses into mov $-4,%rax, an immediate load of the link-time-known TP offset, with the two spare bytes filled by xchg %ax,%ax (the traditional 2-byte nop). The %fs:(%rax) access that followed the resolver call is untouched — because TLSDESC’s resolver already returned a TP-relative offset, the relaxed and unrelaxed forms have the same shape, and only the way %rax is produced changes. That is another quiet advantage of the descriptor design: its relaxation is a local rewrite rather than a re-shaping of the access.
| Compiled as | Linked into | Result |
|---|---|---|
-fPIC general-dynamic, gnu | executable | relaxed to local-exec (mov %fs:0,%rax + lea -0x4(%rax)) |
-fPIC general-dynamic, gnu | shared object, preemptible symbol | not relaxed — real __tls_get_addr@plt call survives |
-fPIC general-dynamic, gnu2 | executable | relaxed to local-exec (mov $-4,%rax + nop) |
-fPIC general-dynamic, gnu2 | shared object, preemptible symbol | not relaxed — indirect descriptor call survives |
initial-exec | executable, symbol defined there | relaxable to local-exec |
local-exec | shared object | link error, not relaxation |
The rule underneath the table: relaxation only ever moves down the ladder, and only when the linker can prove the stronger promise. Never write code that depends on it for correctness — a variable whose access “is local-exec anyway after relaxation” in a -no-pie build is general-dynamic again the moment someone builds the same source -fPIC into a plugin.
Measured Cost of Each Model
Instruction counts are a proxy. The number that matters is what a hot loop costs, so here is one, built as a shared object and dlopened so that each model is exercised in the situation it was designed for:
/* tlsbench_lib.c — built once per (model, dialect) pair */
#include <stdint.h>
__thread volatile uint64_t counter;
uint64_t hammer(long n){ uint64_t s=0; for(long i=0;i<n;i++) s += counter + i; return s; }Methodology. CPU: AMD Ryzen AI MAX+ 395 (Zen 5), 16 cores / 32 threads, cpuinfo_max_freq 5,187.5 MHz. The benchmark thread is pinned with sched_setaffinity() to CPU 4. Each library is warmed with 2,000,000 iterations, then timed over five runs of 200,000,000 iterations each, of which the fastest is reported (minimum, not mean — the minimum is the run least perturbed by interrupts and migration). Wall time comes from clock_gettime(CLOCK_MONOTONIC).
Converting nanoseconds to cycles is where this machine bites. The invariant TSC on this part runs at a fixed 2.9943 GHz while the core boosts to roughly 5.0 GHz — so rdtsc deltas are not core cycles, and treating them as such under-reports the real cycle count by 1 − 2.9943/5.01 ≈ 40%. The core clock is therefore measured separately, with a software tachometer: a loop of K mutually dependent add $1,%reg instructions, each of which has one-cycle latency, so the loop retires exactly one ADD per core cycle regardless of frequency; dividing the ADD count by the elapsed wall time yields the achieved core clock. Cross-checking that against scaling_cur_freq and against three values of K confirms it:
$ ./clk2 0 ; ./clk2 4 ; ./clk2 16
cpu0 TSC=2.9943 GHz | tach K=1: 5.0639 | K=2: 4.8676 | K=4: 5.0150 GHz | sysfs 5.125 GHz
cpu4 TSC=2.9943 GHz | tach K=1: 4.9869 | K=2: 4.9739 | K=4: 5.0815 GHz | sysfs 5.042 GHz
cpu16 TSC=2.9943 GHz | tach K=1: 4.9051 | K=2: 4.9408 | K=4: 4.9925 GHz | sysfs 4.967 GHzThe three K values agree with each other and with the kernel’s own reading to within ~2%, while the TSC disagrees with all of them by a factor of 1.67. Every “core cycles” figure below is ns × core_GHz, with the core clock re-measured inside the same process immediately before the timing run.
$ ./tlsbench hammer ./bl_*.so
core 5.0368 GHz
./bl_global-dynamic_gnu.so 1.040 ns/access 5.24 core cycles/access
./bl_global-dynamic_gnu2.so 0.206 ns/access 1.04 core cycles/access
./bl_local-dynamic_gnu.so 1.026 ns/access 5.17 core cycles/access
./bl_local-dynamic_gnu2.so 0.205 ns/access 1.03 core cycles/access
./bl_initial-exec_gnu.so 0.201 ns/access 1.01 core cycles/access
./bl_initial-exec_gnu2.so 0.200 ns/access 1.01 core cycles/accessA factor of five. But the reason is not the one most people would guess, and the disassembly says so plainly. Under the gnu dialect the resolver call stays inside the loop:
400: data16 lea 0x2bc0(%rip),%rdi # loop body begins here
408: data16 data16 rex.W call 300 <__tls_get_addr@plt>
410: mov (%rax),%rdx
413: add %rbx,%rdx
416: add $0x1,%rbx
41a: add %rdx,%rbp
41d: cmp %rbx,%r12
420: jne 400while under gnu2 it is hoisted out, and the loop reduces to a single load:
3e9: lea 0x2c18(%rip),%rax # before the loop
3f4: call *(%rax) # resolver runs ONCE
3f6: add %fs:0x0,%rax # %rax = absolute address, once
400: mov (%rax),%rsi # loop body: one load
403: add %rdx,%rsi
406: add $0x1,%rdx
40a: add %rsi,%rcx
40d: cmp %rdx,%rdi
410: jne 400So the measured 5× is the cost of the compiler being unable to hoist, not the cost of the resolver’s instructions — which is exactly the property Oliva designed the calling convention to buy (“being able to assume no registers are clobbered by the call tends to improve register allocation”). An attempt to isolate a single resolution by clobbering every call-clobbered register inside the loop body did not change the gnu2 figure at all, because GCC simply parked the resolved address in a callee-saved register (%r12) that an asm statement is not permitted to clobber. The honest statement of the result is therefore:
In a loop that repeatedly touches one thread-local, a
gnu-dialect dynamic access costs about 5.2 core cycles per iteration on this machine, versus about 1.0 cycle for TLSDESC and for initial-exec — and the gap is dominated by the optimisation barrier the classic call imposes, not by the resolver’s own work.
Uncertain
Verify: the isolated cost of a single
__tls_get_addrcall and of a single_dl_tlsdesc_returncall, independent of loop-hoisting effects. Reason: the register-clobber trick used here failed to defeat GCC’s hoisting for thegnu2dialect, so the benchmark measures amortised per-iteration cost rather than per-call cost, and the two numbers are not the same quantity. To resolve: call the resolvers directly from hand-written assembly with a serialising dependency between iterations, or count retired instructions and branch mispredicts with hardware performance counters (perfwas not installed andperf_event_paranoidis 2 on this host). uncertain
Two practical readings follow. First, if a library is already built gnu2 — which on Fedora it is, by default — the usual advice to hand-annotate hot thread-locals with tls_model("initial-exec") buys essentially nothing on the fast path, while costing the library its ability to be dlopened freely. Second, the advice remains sound for a gnu-dialect toolchain, which is what most non-Fedora distributions ship. “Should I force initial-exec?” therefore has a distribution-dependent answer, and the way to settle it is gcc -Q --help=target, not folklore.
When the Model Makes dlopen Fail
The most visible consequence of choosing initial-exec inside a library is that the library stops being freely loadable. Initial-exec requires a fixed offset from the thread pointer, which requires the module’s TLS block to live in the contiguous static TLS area that is laid out when a thread is created. A module loaded by dlopen after threads already exist cannot enlarge that area — the threads’ blocks are already allocated — so glibc reserves a fixed surplus at startup and satisfies late initial-exec modules out of it until it runs out.
glibc 2.43 computes the surplus in elf/dl-tls.c (glibc 2.43):
#define LIBC_IE_TLS 144 /* IE TLS inside libc.so */
#define OTHER_IE_TLS 144 /* IE TLS in other libraries */
#define DEFAULT_NNS 4 /* default dlmopen namespace count */
#define OPTIONAL_TLS 512 /* default glibc.rtld.optional_static_tls */
static inline int
tls_static_surplus (int nns, int opt_tls)
{
return (nns - 1) * LIBC_IE_TLS + nns * OTHER_IE_TLS + opt_tls;
}
/* This value is chosen so that with default values for the tunables,
the computation of dl_tls_static_surplus in
_dl_tls_static_surplus_init yields the historic value 1664, for
backwards compatibility. */
#define LEGACY_TLS (1664 - tls_static_surplus (DEFAULT_NNS, OPTIONAL_TLS))Walk the arithmetic symbol by symbol with the defaults: tls_static_surplus(4, 512) = (4−1)×144 + 4×144 + 512 = 432 + 576 + 512 = 1520, so LEGACY_TLS = 1664 − 1520 = 144, and the surplus the loader actually allocates is 1520 + 144 = 1,664 bytes. The comment is explicit that the number is a compatibility constant reverse-engineered into a formula, not a derived quantity. It is also not a constant in general — it grows with glibc.rtld.nns (each extra dlmopen namespace costs another LIBC_IE_TLS + OTHER_IE_TLS) and with every audit module loaded, and glibc.rtld.optional_static_tls moves it directly.
That is a claim with an obvious experiment attached. Build forty identical libraries, each with a 256-byte initial-exec thread-local, and dlopen them one at a time until one fails:
/* surplus_lib.c, built 40× as surp_1.so … surp_40.so */
#include <stdint.h>
__thread uint8_t blob[256];
int touch(void){ return blob[0]; }$ gcc -O2 -fPIC -shared -ftls-model=initial-exec -o surp_N.so surplus_lib.c # ×40
$ ./surplus_main
dlopen #7 FAILED: ./surp_7.so: cannot allocate memory in static TLS block
=> 6 initial-exec modules x 256 B loaded before exhaustion
$ gcc -O2 -fPIC -shared -ftls-model=global-dynamic -o surp_N.so surplus_lib.c # ×40
$ ./surplus_main
all 40 loaded
$ gcc -O2 -fPIC -shared -ftls-model=initial-exec -o surp_N.so surplus_lib.c # ×40
$ GLIBC_TUNABLES=glibc.rtld.optional_static_tls=8192 ./surplus_main
dlopen #37 FAILED: ./surp_37.so: cannot allocate memory in static TLS block
=> 36 initial-exec modules x 256 B loaded before exhaustionAll three results triangulate the formula. Six modules × 256 B = 1,536 B fits inside 1,664 B and the seventh does not. Switching the same libraries to general-dynamic makes the limit vanish entirely, because their blocks are then allocated on demand off the DTV instead of out of the surplus. And raising optional_static_tls from its default 512 to 8,192 predicts a surplus of 1664 − 512 + 8192 = 9,344 bytes, which admits ⌊9344/256⌋ = 36 modules — precisely the number observed, with the 37th failing.
flowchart TB DL["dlopen(lib.so) after threads exist"] --> Q{"does lib.so contain<br/>initial-exec TLS references?<br/>(R_X86_64_TPOFF64 in its GOT)"} Q -->|"no — general-dynamic<br/>or TLSDESC"| DYN["allocate the block lazily,<br/>off the DTV, on first touch<br/>-> always succeeds"] Q -->|"yes"| S{"does the block fit in the<br/>remaining static TLS surplus?<br/>(1,664 B by default)"} S -->|"yes"| OK["carve it out of the surplus,<br/>fixed TP offset -> fast access"] S -->|"no"| ERR["dlopen returns NULL<br/>'cannot allocate memory<br/>in static TLS block'"] ERR -.->|"fixes"| F1["rebuild the plugin<br/>general-dynamic / gnu2"] ERR -.->|"fixes"| F2["GLIBC_TUNABLES=<br/>glibc.rtld.optional_static_tls=N"] ERR -.->|"fixes"| F3["load the plugin before<br/>the surplus is consumed"]
Why a dlopen fails on a TLS model choice. What it shows: the failure is not about the library’s size or its dependencies — it is decided entirely by whether the library’s TLS references demand a fixed thread-pointer offset, and by how much of a fixed, process-wide budget earlier modules already spent. The insight to take: the error message names static TLS, but the cause is a compile-time model decision made in a library you may not own, which is why the robust fix is to rebuild the plugin rather than to grow the budget.
There is a subtlety in the middle of that picture that explains why the optional_static_tls tunable exists at all, and it points straight back at TLSDESC. glibc’s manual describes the tunable as setting “the amount of surplus static TLS in bytes to allocate at program startup … Optional static TLS is used for optimizing dynamic TLS access for platforms that support such optimizations e.g. TLS descriptors” (glibc 2.43 manual/tunables.texi). In other words that 512-byte slice is not reserved for initial-exec modules at all: it is there so that a dlopened module using dynamic TLS can still be placed in static TLS, which lets ld.so point its descriptors at the two-instruction _dl_tlsdesc_return instead of the DTV-walking _dl_tlsdesc_dynamic. The static/dynamic distinction is a property of placement, and TLSDESC is the only model that can take advantage of a favourable placement it did not know about at compile time.
(The layout of the static TLS block itself, the DTV, and what dlopen does to both are Thread-Local Storage’s subject; this note covers only the part the model decides.)
TLSDESC: Deciding at Run Time Instead of Compile Time
The two dynamic models share a defect that no amount of tuning fixes: they hard-code a compile-time decision about a run-time fact. Alexandre Oliva put the problem precisely when he proposed the alternative: existing general-dynamic and local-dynamic code “did not take advantage of this run-time constant to speed up the case of libraries loaded before program start up, a case that is certainly the most common”, and although “libraries can choose more efficient access models, they can only do so by giving up the ability for the modules that define such variables to be loaded after program start up, since different code sequences have to be generated” (Oliva, Thread-Local Storage Descriptors for IA32 and AMD64/EM64T, Version 0.9.6, 2018-10-11). His design “doesn’t make a compile-time trade off; it rather decides, at run time, how each variable can be accessed most efficiently”.
The mechanism is a TLS descriptor: two words in the GOT, exactly as many as the old tls_index, but used completely differently. The first word is a function pointer; the second is that function’s argument. The psABI states the contract:
R_X86_64_TLSDESCresolves to a pair of word64s, called TLS Descriptor, the first of which is a pointer to a function, followed by an argument. The function is passed a pointer to the this pair of entries in%raxand, using the argument in the second entry, it must compute and return in%raxthe offset from the thread pointer to the symbol referenced in the relocation, without modifying any registers other than processor flags. — x86-64 psABI,object-files.tex
Two clauses in that paragraph carry all the weight. “Offset from the thread pointer”, not an address — so the caller finishes the job with a %fs: access, and the returned value is a thread-independent constant whenever the module is in static TLS. “Without modifying any registers other than processor flags” — so from the compiler’s point of view the call is not a call at all; it clobbers nothing, and the address it produces can be kept in a register, hoisted out of loops, and common-subexpression-eliminated. Oliva names this as a deliberate goal: the convention “penalizes the case that requires dynamic TLS, since it must preserve all call-clobbered registers before calling __tls_get_addr(), but it is optimized for the most common case of static TLS … being able to assume no registers are clobbered by the call tends to improve register allocation.”
Here is what GCC emits for the same read_pub() under -mtls-dialect=gnu2:
$ gcc -O2 -fPIC -c -ftls-model=global-dynamic -mtls-dialect=gnu2 tlsmod.c
$ objdump -dr --no-show-raw-insn tlsmod.o | sed -n '/<read_pub>:/,/^$/p'
0000000000000000 <read_pub>:
0: sub $0x8,%rsp
4: lea 0x0(%rip),%rax # b <read_pub+0xb>
7: R_X86_64_GOTPC32_TLSDESC tls_pub-0x4
b: call *(%rax)
b: R_X86_64_TLSDESC_CALL tls_pub
d: mov %fs:(%rax),%eax
10: add $0x8,%rsp
14: retR_X86_64_GOTPC32_TLSDESC makes the linker allocate the descriptor and points %rax at it. call *(%rax) is an indirect call through the descriptor’s first word; the zero-length R_X86_64_TLSDESC_CALL relocation carries no value at all — it exists purely to mark this instruction so the linker knows it may rewrite it during relaxation (“R_X86_64_TLSDESC_CALL must annotate the instruction used to call the TLS Descriptor resolver function, so as to enable relaxation of that instruction”). Then mov %fs:(%rax),%eax adds the returned offset to the thread pointer implicitly, via the segment base.
sequenceDiagram autonumber participant C as compiled code<br/>in the DSO participant G as GOT descriptor<br/>{fn, arg} participant R as resolver chosen<br/>by ld.so at relocation time participant D as DTV / static TLS Note over G,R: ld.so decides ONCE, at load time,<br/>which resolver this symbol needs C->>G: lea x@TLSDESC(%rip), %rax C->>R: call *(%rax) [indirect, via descriptor word 0] alt module is in STATIC TLS (common case) R->>R: _dl_tlsdesc_return — movq 8(%rax),%rax then ret Note right of R: 2 instructions, no memory<br/>traffic beyond the descriptor else module is in DYNAMIC TLS R->>D: read %fs:DTV_OFFSET, check generation count alt DTV current and block allocated R->>R: dtv[mod].val + off − TP<br/>(fast path, red-zone spills only) else stale or unallocated R->>R: save full FPU/vector state,<br/>call __tls_get_addr_internal, subtract TP end end R-->>C: %rax = offset from thread pointer C->>D: mov %fs:(%rax), %eax
The TLSDESC two-call protocol. What it shows: the branch on “static or dynamic TLS” is taken once, at relocation time, by choosing which function pointer to store in the descriptor — not on every access. The insight to take: this is the whole idea. The classic models pay for generality on every access because the decision lives in the instruction stream; TLSDESC moves the decision into a data word that the dynamic linker writes once, so the access site pays only for the case it actually has.
The three resolvers, as glibc actually implements them
glibc provides exactly the resolvers that design implies. In sysdeps/x86_64/dl-tlsdesc.S (glibc 2.43) the static-TLS resolver is, in its entirety:
/* This function is used to compute the TP offset for symbols in
Static TLS, i.e., whose TP offset is the same for all threads. */
_dl_tlsdesc_return:
_CET_ENDBR
movq 8(%rax), %rax /* descriptor word 1 = the TP offset */
retOne load and a return. The _CET_ENDBR is the Control-flow Enforcement Technology landing pad required because this is reached by an indirect call. There is a second trivial resolver, _dl_tlsdesc_undefweak, for weak TLS symbols with no definition; it returns addend − TP “such that, when the caller adds TP, it gets the addend back. If that’s zero, as usual, that’s most likely a NULL pointer” — a neat way to make an undefined weak thread-local read as NULL through a mechanism that only knows how to produce TP-relative offsets.
The third resolver, _dl_tlsdesc_dynamic, is the one that does real work, and its structure repays reading. glibc documents it with the equivalent C in the source (sysdeps/x86_64/dl-tlsdesc-dynamic.h, glibc 2.43):
ptrdiff_t
_dl_tlsdesc_dynamic (register struct tlsdesc *tdp asm ("%rax"))
{
struct tlsdesc_dynamic_arg *td = tdp->arg;
dtv_t *dtv = *(dtv_t **)((char *)__thread_pointer + DTV_OFFSET);
if (__builtin_expect (td->gen_count <= dtv[0].counter
&& (dtv[td->tlsinfo.ti_module].pointer.val
!= TLS_DTV_UNALLOCATED), 1))
return dtv[td->tlsinfo.ti_module].pointer.val + td->tlsinfo.ti_offset
- __thread_pointer;
return __tls_get_addr_internal (&td->tlsinfo) - __thread_pointer;
}and the assembly fast path spills only two registers — into the red zone, below %rsp, with no stack frame at all:
_dl_tlsdesc_dynamic:
_CET_ENDBR
movq %rsi, -16(%rsp) /* red-zone spill, no push */
mov %fs:DTV_OFFSET, %RSI_LP /* this thread's DTV */
movq %rdi, -8(%rsp)
movq TLSDESC_ARG(%rax), %rdi
movq (%rsi), %rax /* dtv[0].counter — generation */
cmpq %rax, TLSDESC_GEN_COUNT(%rdi)
ja 2f /* stale generation -> slow path */
movq TLSDESC_MODID(%rdi), %rax
salq $4, %rax /* × sizeof(dtv_t) == 16 */
movq (%rax,%rsi), %rax
cmpq $-1, %rax /* TLS_DTV_UNALLOCATED */
je 2f
addq TLSDESC_MODOFF(%rdi), %rax
1: movq -16(%rsp), %rsi
sub %fs:0, %RAX_LP /* convert address -> TP offset */
movq -8(%rsp), %rdi
ret
2: /* slow path: save the ENTIRE extended register state, then call
__tls_get_addr_internal */The slow path is where Oliva’s “penalizes the case that requires dynamic TLS” becomes literal: because the descriptor convention promised to clobber nothing, the resolver must save every register it is about to destroy, including the vector state, before it can call into C. glibc therefore compiles three separate copies of this function — _dl_tlsdesc_dynamic_fxsave, _dl_tlsdesc_dynamic_xsave, and _dl_tlsdesc_dynamic_xsavec — and selects among them by CPU feature, with STATE_SAVE_ALIGNMENT 16 for FXSAVE and 64 for the XSAVE variants. A “register-lean” calling convention is register-lean only on the path that does not have to call anything.
_TLS_MODULE_BASE_: what local-dynamic becomes under TLSDESC
Under the gnu dialect, general-dynamic and local-dynamic emit visibly different sequences (TLSGD vs TLSLD). Under gnu2 they converge on one sequence, and the distinction moves into the symbol the descriptor is attached to:
$ gcc -O2 -fPIC -c -ftls-model=global-dynamic -mtls-dialect=gnu2 tlsmod.c
$ objdump -dr --no-show-raw-insn tlsmod.o | sed -n '/<read_loc>:/,/^$/p'
0000000000000020 <read_loc>:
20: sub $0x8,%rsp
24: lea 0x0(%rip),%rax # 2b <read_loc+0xb>
27: R_X86_64_GOTPC32_TLSDESC _TLS_MODULE_BASE_-0x4
2b: call *(%rax)
2b: R_X86_64_TLSDESC_CALL _TLS_MODULE_BASE_
2d: mov %fs:0x0(%rax),%edx
30: R_X86_64_DTPOFF32 tls_loc2
34: add %fs:0x0(%rax),%edx
37: R_X86_64_DTPOFF32 tls_loc1
3b: add $0x8,%rsp
3f: mov %edx,%eax
41: retThe descriptor is bound to the linker-synthesised symbol _TLS_MODULE_BASE_, which denotes the start of this module’s TLS block, and the per-variable displacements ride along as R_X86_64_DTPOFF32 immediates exactly as in the classic local-dynamic sequence. Compare the two carefully and one structural difference stands out: the classic sequence’s follow-up accesses are mov 0x0(%rax),%edx — no segment prefix — because __tls_get_addr returned an absolute address; the TLSDESC sequence’s are mov %fs:0x0(%rax),%edx — with the prefix — because the resolver returned a TP-relative offset. That single prefix is the visible signature of the whole design change.
gnu versus gnu2, compared
gnu (classic) | gnu2 (TLSDESC) | |
|---|---|---|
| GOT words per general-dynamic symbol | 2 (tls_index = module, offset) | 2 (descriptor = fn pointer, arg) |
| Dynamic relocations emitted | 2 — DTPMOD64 + DTPOFF64 | 1 — TLSDESC |
| Call target | __tls_get_addr@plt, a real PLT call | indirect call *(%rax) through descriptor word 0 |
| Resolver return value | absolute address of the variable | offset from the thread pointer |
| Registers clobbered (as the compiler models it) | all call-clobbered registers | none but flags |
| Static-TLS fast path | still a full call into __tls_get_addr | movq 8(%rax),%rax ; ret |
| Address hoistable out of a loop by GCC? | no | yes — measured below |
| Undefined symbol left in the object | U __tls_get_addr@GLIBC_2.3 | none |
| Dynamic-section fingerprint | none specific | DT_TLSDESC_PLT, DT_TLSDESC_GOT |
| Local-dynamic encoding | R_X86_64_TLSLD on a real symbol | GOTPC32_TLSDESC on _TLS_MODULE_BASE_ |
| Lazy relocation possible | no | yes, by design (DT_TLSDESC_PLT names the lazy trampoline) |
| Affects initial-exec / local-exec? | no | no — byte-identical output |
TLSDESC is used on “aarch64, arm, i386, and x86-64” (MaskRay 2021), is the only dialect on AArch64, and — as measured at the top of this note — is the shipped default on Fedora’s x86-64 GCC.
Where the Thread Pointer Comes From: the Kernel Side
Every sequence in this note bottoms out in %fs: — and %fs’s base is not something userspace magically owns. It is a per-task CPU register that the kernel saves and restores on every context switch, which means the whole edifice of TLS access models rests on one specific pair of instructions in the scheduler.
On Linux/x86-64 that pair lives in __switch_to(). Saving the outgoing task’s thread pointer (arch/x86/kernel/process_64.c, Linux v6.12):
static __always_inline void save_fsgs(struct task_struct *task)
{
savesegment(fs, task->thread.fsindex);
savesegment(gs, task->thread.gsindex);
if (static_cpu_has(X86_FEATURE_FSGSBASE)) {
task->thread.fsbase = rdfsbase();
task->thread.gsbase = __rdgsbase_inactive();
} else { ... }
}and restoring the incoming one:
static __always_inline void x86_fsgsbase_load(struct thread_struct *prev,
struct thread_struct *next)
{
if (static_cpu_has(X86_FEATURE_FSGSBASE)) {
...
wrfsbase(next->fsbase);
__wrgsbase_inactive(next->gsbase);
} else { ... }
}task->thread.fsbase is the thread pointer. When a pthread_created thread runs mov %fs:0xfffffffffffffffc,%eax, the reason it reads its copy of the variable and not another thread’s is that wrfsbase() put its TCB address into the FS base microseconds earlier. Two notes’ worth of machinery meet at that one word: this note describes what userspace does with the thread pointer; Context Switch Mechanics switch_to and switch_mm describes how the kernel makes the thread pointer be the right one, and why rdfsbase/wrfsbase (the FSGSBASE CPU feature) rather than rdmsr/wrmsr on MSR_FS_BASE matters for switch cost.
Failure Modes and Common Misunderstandings
-
R_X86_64_TPOFF32link error against a shared object. Compiling a library’s TLS access as local-exec (forgetting-fPIC, or forcing-ftls-model=local-exec) yields the error reproduced above: “relocationR_X86_64_TPOFF32… can not be used when making a shared object; local-exec is incompatible with-shared”. The model promised the variable was in the executable at a link-time-known offset; it is in a.so. Fix: build with-fPIC, which defaults to general-dynamic. -
dlopenfailing with “cannot allocate memory in static TLS block”. An initial-exec moduledlopened after the 1,664-byte static-TLS surplus is used up — demonstrated above at exactly the seventh 256-byte module. Fixes, in order of robustness: rebuild the plugin without initial-exec; raiseGLIBC_TUNABLES=glibc.rtld.optional_static_tls=N; load the plugin earlier. Note that the failingdlopenis usually of a library you do not control, which is why the tunable exists at all — glibc’s own comment forOTHER_IE_TLSsays the reserve “should be large enough to cover runtime libraries of the compiler such as libgomp”. -
Grepping for
__tls_get_addrto detect dynamic TLS. Measured above to give the wrong answer on anygnu2toolchain — the symbol is simply absent from TLSDESC objects that use dynamic TLS heavily. Checkreadelf -WrforDTPMOD64/DTPOFF64/TLSDESC, orreadelf -dforDT_TLSDESC_PLT. -
Believing
gcc(1)about the dialect default. The manual saysgnu; Fedora’s GCC saysgnu2. Askgcc -Q --help=target. The same caution applies in the other direction on distributions that have not switched: benchmarks and advice written on Fedora do not transfer. -
Treating
-ftls-model=as binding. It is a ceiling the compiler may lower for non-preemptible symbols (demonstrated:global-dynamicproducingR_X86_64_TLSLD), and an assertion the compiler will honour even when it is unsound (local-dynamicon a preemptible symbol). Neither direction is checked for you. -
Reading the object file and concluding you know the code. The linker relaxes.
objdumpthe linked artifact, not the.o, when the question is “what does this program actually execute”. -
Assuming the linker will always relax. Relaxation is opportunistic and one-directional (only down the ladder), and it depends on
-pie/-no-pie, symbol preemptibility and the output type. Rely on it for speed, never for correctness. -
Reading a
@tpoffoffset as an address. On Variant II architectures (x86-64 included) static-model offsets are negative;objdumpprints them sign-extended as0xfffffffffffffffc-style constants. That is-4, not a corrupt pointer. -
Confusing the TP register across architectures.
%fson x86-64,%gson i386,TPIDR_EL0on AArch64, and the variant differs too (I vs II), so the sign of the offset differs. Inline assembly touching the thread pointer is not portable, and neither is a hard-coded offset. -
Assuming TLSDESC is free in the dynamic case. Its static case is two instructions; its dynamic slow path saves the entire extended register state (
FXSAVE/XSAVE/XSAVEC, whichever glibc selected) before it can call__tls_get_addr_internal. TLSDESC optimises the common case aggressively and pays for it in the uncommon one — by design. -
Expecting the stack canary to behave like a thread-local. It is read as
%fs:0x28, which looks exactly like a local-exec TLS access — and indeedgcc -Q --help=targetreports-mstack-protector-guard=tlson this box — but the canary value is established per process and copied into each new thread’s TCB, so it is not per-thread-random. Measured:$ ./canary # reads %fs:0x28 from main and two pthreads main canary = 0x39c6fc59a5f82b00 thread 1 canary = 0x39c6fc59a5f82b00 thread 2 canary = 0x39c6fc59a5f82b00 $ ./canary # a second process main canary = 0xdb742581965bbc00 thread 1 canary = 0xdb742581965bbc00 thread 2 canary = 0xdb742581965bbc00Identical across threads, different across processes. (The trailing
0x00byte is deliberate: it terminates C strings, so astrcpy-style overflow cannot reproduce the canary without writing a NUL.)tcbhead_tand that field belong to The Thread Control Block.
See Also
- Thread-Local Storage — the
.tdata/.tbsstemplate, the DTV, and static vs dynamic allocation this note addresses - The Thread Control Block — what the thread pointer (
%fsbase) points at,tcbhead_t, and the%fs:0x28canary slot - Context Switch Mechanics switch_to and switch_mm — the kernel side:
__switch_to()saving and restoringFSBASE, which is the thread pointer every sequence in this note dereferences - The Global Offset Table — where initial-exec/dynamic models store their offsets and
tls_index - ELF Relocation Entries — the relocation machinery (
R_X86_64_*) these sequences emit - Position-Independent Code and PIE — why
-fPICcode defaults to general-dynamic - POSIX Threads and NPTL — where per-thread TLS blocks are allocated at
clonetime - Compilers and Toolchains MOC — parent map; this note is the linker/ABI side of thread-local data
- Linux Userspace Runtime MOC — parent map (§7, Threads and Thread-Local Storage)