You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
They preserve ordinary Go variable syntax: reads, writes, address-taking,
closures, and atomics use the current owner's stable address. TLS and GLS are
separate source and initializer kinds. The current one-pthread-per-goroutine
runtime maps both to the same physical backend; a future scheduler may move GLS
to goroutine context storage without changing source declarations.
Locality variables must be package-level and unexported. They cannot use go:linkname, go:embed, or the blank identifier. Cross-package use therefore
goes through ordinary exported functions compiled in the package that owns the
local variable.
Package boundaries
The implementation keeps directive semantics separate from lowering:
internal/directive only parses and normalizes directives.
internal/locality validates TLS/GLS declarations and prepares initializer
helpers.
internal/locality/layout is a pure type/layout planner with no SSA, LLVM,
target, build-cache, or runtime dependency.
ssa stores program-wide locality metadata and emits local-context/accessor
code.
cl/locality_lower.go integrates the plan into normal variable lowering.
internal/build preloads source metadata before cached package compilation
and fingerprints whether entry-context instrumentation is required.
runtime/internal/runtime owns allocation, GC reachability, and teardown.
No target name or new build tag is inspected by the compiler. When native TLS
is selected, LLVM TLS is emitted. Bare-metal runtime files continue to choose
their runtime implementation through existing source constraints; the language
layer does not reject TLS/GLS declarations for a target.
Compilation and link flow
For every package, before function bodies are lowered:
The source prepass records directives and go:linkname metadata, including
packages whose compiled module may come from cache.
internal/locality creates explicit variable-initializer helpers while
preserving Go initialization order.
layout.Plan classifies each variable as native TLS or a field in the
package's GC-visible payload.
The package module defines its native TLS globals, package cache/accessor,
initializer guards, dispatcher, and ensure functions.
Function lowering uses fixed field indexes from the plan. Package bases and
successful initializer checks are reused along SSA dominator paths.
A package with a GC-visible payload defines this pair:
The cache is package-owned. Because p's locality variables are unexported,
another package calls an exported function in p; that function uses the same p.__llgo_local_cache. Importers do not define or directly access another copy
of p's local storage.
After package modules are built or loaded:
The generated main entry reserves a candidate LocalContext, enters it before
package initialization, and leaves it after main.main returns.
Every generated pthread wrapper for go f(...) reserves and enters a fresh
context before invoking the Go call.
C-exported Go entries use the same enter/leave sequence. A nested entry reuses
an already active context.
Runtime, package, and main modules are linked normally.
flowchart TD
A[Load source and type information] --> B[Parse directives and linknames]
B --> C[Prepare initializer helpers]
C --> D[Plan native TLS and package payloads]
D --> E[Compile or load package modules]
E --> F[Emit package caches, accessors, guards, dispatchers]
D --> G{Program needs LocalContext?}
G --> H[Instrument main entry]
G --> I[Instrument goroutine wrappers]
G --> J[Instrument C-exported entries]
F --> K[Link packages, runtime, and main]
H --> K
I --> K
J --> K
Loading
Storage selection
Variable type
Current TLS backend
Current GLS backend
Pointer-free
native LLVM thread_local
native LLVM thread_local
GC-visible
field in one lazy package payload
field in the same package payload
Pointer-bearing TLS and GLS fields in one package share one payload. Their
initializer guards remain separate by locality kind.
On a 64-bit hosted target, one active owner has this layout:
native TLS for package p
p.count uint64 8 B
p.__llgo_local_cache uintptr 8 B, non-root payload cache
p.__llgo_tls_init$guard uint8 only with explicit TLS initializers
p.__llgo_gls_init$guard uint8 only with explicit GLS initializers
outer Go entry stack
LocalContext
blocks -------------------------------+
|
GC allocation created when p is touched |
[leading alignment padding] |
[next payload pointer 8 B] |
[cacheSlot -> &p.__llgo_local_cache 8 B] |
[aligned p payload] <--------------------+
current *Node 8 B
state.head *Entry 8 B
state.n int 8 B
LocalContext.blocks and each header's next field hold payload addresses.
The stack pointer is the GC root. The TLS cache is a uintptr and intentionally
is not a GC root.
The list is allocation/root order only. It is not an LRU and is never searched
on access. For example, after touching packages p, q, and r:
An access to p loads p.__llgo_local_cache directly; it does not walk r -> q -> p and does not reorder the list. The list exists only so the GC can
reach every active block and so owner teardown can clear every cache slot.
Initializer failure values use a separate failure cache/block only after a
panic.
Runtime access flow
The always-inline package accessor is conceptually:
verifies that an outer Go entry context is active;
allocates one zeroed, aligned header plus package payload;
prepends it to LocalContext.blocks for GC reachability;
writes the payload address to the package TLS cache; and
returns the payload.
Every later uncached function path performs one O(1) TLS cache check. Within a
function, the compiler can reuse a package base along dominated paths and omit
additional checks. There is no hash lookup, list search, lock, locality pthread.Key, or per-variable root registration.
At owner exit, the runtime walks the root list once, clears each recorded TLS
cache slot, and severs next and cacheSlot links. It does not explicitly free
a block: an escaped address can keep that block alive, while cleared links stop
it from retaining unrelated blocks.
sequenceDiagram
participant C as Compiled access in package p
participant A as p.__llgo_local_block
participant T as p TLS cache
participant R as runtime.LocalPackage
participant X as LocalContext root list
C->>A: request p payload
A->>T: load p.__llgo_local_cache
alt cache hit
T-->>A: p payload
A-->>C: typed payload
else first touch
A->>R: cache address, size, alignment
R->>X: allocate and prepend rooted block
R->>T: cache p payload
R-->>A: p payload
A-->>C: typed payload
end
C->>C: add fixed field offset
Loading
Main and goroutine execution
Initial main owner
The generated executable entry installs the main LocalContext before Go
package initialization.
Each package marks its local initializer guards ready for the initial owner.
Normal Go package initialization executes the original variable
initializers once in dependency/order semantics.
Pointer-free writes go to native TLS. GC-visible writes obtain the package
payload and store at their fixed field offsets.
main.main uses the same context and sees those initialized values.
New goroutine owner
The pthread wrapper starts with zeroed native TLS and an empty LocalContext.
It does not enumerate packages, allocate package blocks, or replay
initializers eagerly.
The first access to package p processes only p at that point. If code in p then calls package q, q is handled independently when accessed.
Another goroutine has different TLS slots and a different context.
Payload allocation remains sparse: a goroutine that touches only p and q
allocates payloads only for those packages. The direct cache itself costs one
fixed TLS word per package that has a GC-visible payload.
Separate outer calls from the same foreign pthread are a documented boundary:
this proposal does not promise package-payload persistence across those outer
entries.
explicit initializers are replayed lazily when that package/locality kind is
first accessed;
a is assigned 1;
makeB() is called once;
pair() is called once for the grouped declaration;
pure zero-value x has no helper; and
Go func init() is not replayed and keeps normal once-per-program behavior.
One dispatcher and one native TLS guard are generated per package/locality kind,
not per variable. First access to any variable of that kind runs all explicit
helpers for the kind in Go initialization order. Recursive access observes
partial initialization. A failed initializer remains failed and re-panics with
the saved value on later accesses, including panic(nil); its any payload is
allocated only on failure.
The ready guard check is always-inline, and successful checks/package bases are
reused along dominated paths. This avoids repeated helper calls while preserving
the lazy execution point.
Cost model
64-bit condition
Cost
context-enabled outer Go entry
one 8-byte candidate stack slot; only the outer slot is the root
package with a GC-visible payload
one 8-byte native TLS cache per pthread, even if that package is untouched
one native TLS guard byte and one 8-byte failure-cache slot
successful initializer
no failure block
failed initializer
one lazy block containing an any value plus the normal 16-byte header
owner exit
one linear walk over blocks allocated by that owner
Programs containing only zero-initialized, pointer-free native TLS locals do not
need a LocalContext. The hot access path has no lock, heap allocation, package
list traversal, or pthread_getspecific call. Cold first-touch allocation is
not optimized by this proposal.
Measurements
Apple M4 Max, macOS arm64, Go 1.26.5, LLVM 19.1.7, real LLGo, GOMAXPROCS=2, -count=8 -benchtime=750ms -benchmem unless noted.
All report 0 B/op, 0 allocs/op. Direct GLS is about 0.118 ns, or 9.1%,
over native TLS in this run.
The retained pre-migration runtime benchmark records the old pthread-key cost:
Runtime access path
Earlier 1s x 5 range
Current median
Current 750ms x 8 range
Heap accounting
pthread-key GetThreadDefer
2.321-2.337 ns/op
2.323 ns/op
2.310-2.363 ns/op
0 B/op, 0 allocs/op
#2079 intentionally leaves GetThreadDefer on its existing pthread_getspecific implementation, so this is a reproducible old-runtime
baseline for the follow-up migration. It is not the same function as the
generic reader table above; a same-function comparison belongs in the
runtime-state follow-up.
The retained alternating benchmark performs two serialized GLS reads per op.
One of its packages also has explicit GLS initializers, so that read checks the
shared package/kind initializer guard before reading its payload:
Working set
Previous list batch median
Direct-cache batch median
Direct-cache ns/read
Current range
alternating two packages, two reads/op
7.334 ns/op
3.510 ns/op
1.755
3.490-3.543 ns/op
This is an apples-to-apples backend regression benchmark: direct caches reduce
the same guarded workload by 52.1%. It is not a pure two-read latency estimate.
The zero-initializer independent-package benchmark below is the scaling check.
Each independent working-set op is one batch containing N independent Read calls, so batch ns/op is expected to grow with N:
Batch size (reads/op)
Median batch ns/op
Throughput ns/read
Heap accounting
2
2.656
1.328
0 B/op, 0 allocs/op
4
5.161
1.290
0 B/op, 0 allocs/op
8
9.937
1.242
0 B/op, 0 allocs/op
The per-read throughput does not increase as the working set grows from 2 to 8
packages, confirming that hot access no longer depends on touched-package
count. The slight decrease comes from independent calls overlapping; this
table measures throughput scaling, not single-read latency below the 1.418
ns/op result.
Fixed-count goroutine measurements also show no space regression: entry is
1552-1560 B/op versus the previous 1570-1572 B/op, and first package touch is
1583-1591 B/op versus 1592-1606 B/op. These counters are allocator/run-sensitive;
the relevant conclusion is that the direct cache did not add measured per-entry
allocation.
Final macOS arm64 disassembly contains no __llgo_local_block symbol after
optimization. The hit sequence is inlined into ReadGLSPackage; only the zero
cache branch calls runtime.LocalPackage.
Validation
macOS arm64, Go 1.26.5, LLVM 19.1.7: full ssa, focused compiler locality,
codegen FileCheck, test/llgoext, and test/llgoext/localitymulti pass.
Ubuntu ARM64 container, Go 1.24.2, LLVM 19.1.7, 2 CPU and 15 GiB limit: both
real-LLGo test packages pass.
Ubuntu AMD64 container: focused internal/locality/..., ssa, and cl pass;
the root real-LLGo locality suite passes. QEMU itself was unstable while
compiling the separate multi-package suite, so native AMD64 CI remains the
authoritative run for that architecture.
internal/locality and internal/locality/layout are each 100% covered.
All 63 instrumented changed production lines in the root Go module are hit,
with zero uncovered or partial lines. Runtime is a nested module and is
covered by the real-LLGo integration tests.
No existing test is skipped or ignored.
Implementation: #2079. The GLS runtime-state consumer remains a follow-up after
this facility is green.
Summary
Add two package-variable directives:
They preserve ordinary Go variable syntax: reads, writes, address-taking,
closures, and atomics use the current owner's stable address. TLS and GLS are
separate source and initializer kinds. The current one-pthread-per-goroutine
runtime maps both to the same physical backend; a future scheduler may move GLS
to goroutine context storage without changing source declarations.
Locality variables must be package-level and unexported. They cannot use
go:linkname,go:embed, or the blank identifier. Cross-package use thereforegoes through ordinary exported functions compiled in the package that owns the
local variable.
Package boundaries
The implementation keeps directive semantics separate from lowering:
internal/directiveonly parses and normalizes directives.internal/localityvalidates TLS/GLS declarations and prepares initializerhelpers.
internal/locality/layoutis a pure type/layout planner with no SSA, LLVM,target, build-cache, or runtime dependency.
ssastores program-wide locality metadata and emits local-context/accessorcode.
cl/locality_lower.gointegrates the plan into normal variable lowering.internal/buildpreloads source metadata before cached package compilationand fingerprints whether entry-context instrumentation is required.
runtime/internal/runtimeowns allocation, GC reachability, and teardown.No target name or new build tag is inspected by the compiler. When native TLS
is selected, LLVM TLS is emitted. Bare-metal runtime files continue to choose
their runtime implementation through existing source constraints; the language
layer does not reject TLS/GLS declarations for a target.
Compilation and link flow
For every package, before function bodies are lowered:
go:linknamemetadata, includingpackages whose compiled module may come from cache.
internal/localitycreates explicit variable-initializer helpers whilepreserving Go initialization order.
layout.Planclassifies each variable as native TLS or a field in thepackage's GC-visible payload.
initializer guards, dispatcher, and ensure functions.
successful initializer checks are reused along SSA dominator paths.
A package with a GC-visible payload defines this pair:
The cache is package-owned. Because
p's locality variables are unexported,another package calls an exported function in
p; that function uses the samep.__llgo_local_cache. Importers do not define or directly access another copyof
p's local storage.After package modules are built or loaded:
LocalContext, enters it beforepackage initialization, and leaves it after
main.mainreturns.go f(...)reserves and enters a freshcontext before invoking the Go call.
an already active context.
flowchart TD A[Load source and type information] --> B[Parse directives and linknames] B --> C[Prepare initializer helpers] C --> D[Plan native TLS and package payloads] D --> E[Compile or load package modules] E --> F[Emit package caches, accessors, guards, dispatchers] D --> G{Program needs LocalContext?} G --> H[Instrument main entry] G --> I[Instrument goroutine wrappers] G --> J[Instrument C-exported entries] F --> K[Link packages, runtime, and main] H --> K I --> K J --> KStorage selection
thread_localthread_localPointer-bearing TLS and GLS fields in one package share one payload. Their
initializer guards remain separate by locality kind.
Runtime memory layout
Example package:
On a 64-bit hosted target, one active owner has this layout:
LocalContext.blocksand each header'snextfield hold payload addresses.The stack pointer is the GC root. The TLS cache is a
uintptrand intentionallyis not a GC root.
The list is allocation/root order only. It is not an LRU and is never searched
on access. For example, after touching packages
p,q, andr:An access to
ploadsp.__llgo_local_cachedirectly; it does not walkr -> q -> pand does not reorder the list. The list exists only so the GC canreach every active block and so owner teardown can clear every cache slot.
Initializer failure values use a separate failure cache/block only after a
panic.
Runtime access flow
The always-inline package accessor is conceptually:
On first touch,
LocalPackage:LocalContext.blocksfor GC reachability;Every later uncached function path performs one O(1) TLS cache check. Within a
function, the compiler can reuse a package base along dominated paths and omit
additional checks. There is no hash lookup, list search, lock, locality
pthread.Key, or per-variable root registration.At owner exit, the runtime walks the root list once, clears each recorded TLS
cache slot, and severs
nextandcacheSlotlinks. It does not explicitly freea block: an escaped address can keep that block alive, while cleared links stop
it from retaining unrelated blocks.
sequenceDiagram participant C as Compiled access in package p participant A as p.__llgo_local_block participant T as p TLS cache participant R as runtime.LocalPackage participant X as LocalContext root list C->>A: request p payload A->>T: load p.__llgo_local_cache alt cache hit T-->>A: p payload A-->>C: typed payload else first touch A->>R: cache address, size, alignment R->>X: allocate and prepend rooted block R->>T: cache p payload R-->>A: p payload A-->>C: typed payload end C->>C: add fixed field offsetMain and goroutine execution
Initial main owner
LocalContextbefore Gopackage initialization.
initializers once in dependency/order semantics.
payload and store at their fixed field offsets.
main.mainuses the same context and sees those initialized values.New goroutine owner
LocalContext.initializers eagerly.
pprocesses onlypat that point. If code inpthen calls packageq,qis handled independently when accessed.Payload allocation remains sparse: a goroutine that touches only
pandqallocates payloads only for those packages. The direct cache itself costs one
fixed TLS word per package that has a GC-visible payload.
Separate outer calls from the same foreign pthread are a documented boundary:
this proposal does not promise package-payload persistence across those outer
entries.
Initializer semantics
For each new owner:
first accessed;
ais assigned1;makeB()is called once;pair()is called once for the grouped declaration;xhas no helper; andfunc init()is not replayed and keeps normal once-per-program behavior.One dispatcher and one native TLS guard are generated per package/locality kind,
not per variable. First access to any variable of that kind runs all explicit
helpers for the kind in Go initialization order. Recursive access observes
partial initialization. A failed initializer remains failed and re-panics with
the saved value on later accesses, including
panic(nil); itsanypayload isallocated only on failure.
The ready guard check is always-inline, and successful checks/package bases are
reused along dominated paths. This avoids repeated helper calls while preserving
the lazy execution point.
Cost model
anyvalue plus the normal 16-byte headerPrograms containing only zero-initialized, pointer-free native TLS locals do not
need a
LocalContext. The hot access path has no lock, heap allocation, packagelist traversal, or
pthread_getspecificcall. Cold first-touch allocation isnot optimized by this proposal.
Measurements
Apple M4 Max, macOS arm64, Go 1.26.5, LLVM 19.1.7, real LLGo,
GOMAXPROCS=2,-count=8 -benchtime=750ms -benchmemunless noted.Comparable imported
//go:noinline func() uintptrreaders:All report
0 B/op, 0 allocs/op. Direct GLS is about 0.118 ns, or 9.1%,over native TLS in this run.
The retained pre-migration runtime benchmark records the old pthread-key cost:
GetThreadDefer#2079 intentionally leaves
GetThreadDeferon its existingpthread_getspecificimplementation, so this is a reproducible old-runtimebaseline for the follow-up migration. It is not the same function as the
generic reader table above; a same-function comparison belongs in the
runtime-state follow-up.
The retained alternating benchmark performs two serialized GLS reads per op.
One of its packages also has explicit GLS initializers, so that read checks the
shared package/kind initializer guard before reading its payload:
This is an apples-to-apples backend regression benchmark: direct caches reduce
the same guarded workload by 52.1%. It is not a pure two-read latency estimate.
The zero-initializer independent-package benchmark below is the scaling check.
Each independent working-set op is one batch containing N independent
Readcalls, so batchns/opis expected to grow with N:The per-read throughput does not increase as the working set grows from 2 to 8
packages, confirming that hot access no longer depends on touched-package
count. The slight decrease comes from independent calls overlapping; this
table measures throughput scaling, not single-read latency below the 1.418
ns/op result.
Fixed-count goroutine measurements also show no space regression: entry is
1552-1560 B/op versus the previous 1570-1572 B/op, and first package touch is
1583-1591 B/op versus 1592-1606 B/op. These counters are allocator/run-sensitive;
the relevant conclusion is that the direct cache did not add measured per-entry
allocation.
Final macOS arm64 disassembly contains no
__llgo_local_blocksymbol afteroptimization. The hit sequence is inlined into
ReadGLSPackage; only the zerocache branch calls
runtime.LocalPackage.Validation
ssa, focused compiler locality,codegen FileCheck,
test/llgoext, andtest/llgoext/localitymultipass.real-LLGo test packages pass.
internal/locality/...,ssa, andclpass;the root real-LLGo locality suite passes. QEMU itself was unstable while
compiling the separate multi-package suite, so native AMD64 CI remains the
authoritative run for that architecture.
internal/localityandinternal/locality/layoutare each 100% covered.with zero uncovered or partial lines. Runtime is a nested module and is
covered by the real-LLGo integration tests.
Implementation: #2079. The GLS runtime-state consumer remains a follow-up after
this facility is green.