Skip to content

compiler/runtime: add //llgo:tls and //llgo:gls package variables#2079

Open
cpunion wants to merge 12 commits into
xgo-dev:mainfrom
cpunion:codex/threadlocal-getg
Open

compiler/runtime: add //llgo:tls and //llgo:gls package variables#2079
cpunion wants to merge 12 commits into
xgo-dev:mainfrom
cpunion:codex/threadlocal-getg

Conversation

@cpunion

@cpunion cpunion commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

LLGo runtime state is often owned by one pthread or goroutine, but package code
currently has to use pthread.Key accessors or process globals. That adds key
management and lookup overhead, prevents ordinary Go variable access, and makes
GC ownership difficult for pointer-bearing state.

Without this PR, LLGo cannot declare addressable thread-local or goroutine-local
package variables with normal Go syntax and a GC-rooted lifetime model.

This PR implements #2077:

//llgo:tls
var threadState T

//llgo:gls
var goroutineState U

TLS and GLS remain separate source and initializer kinds. They share the current
physical backend because LLGo maps one goroutine to one pthread; GLS can move to
a future goroutine context without changing declarations.

Locality variables are package-level and unexported. They cannot use
go:linkname, go:embed, or _. Cross-package use goes through ordinary
exported functions compiled in the package that owns the variable.

Implementation

Storage and hot access

  • Pointer-free TLS/GLS values use native LLVM thread_local storage.
  • GC-visible TLS/GLS fields in one package share one lazily allocated package
    payload.
  • A package with such a payload defines one native TLS uintptr cache and one
    always-inline accessor:
p.__llgo_local_cache  -> p payload for this owner, or zero
p.__llgo_local_block  -> inline cache check, cold LocalPackage fallback

The hit path is O(1): load the package TLS cache, branch on zero, and return the
payload. It does not inspect another package, traverse a list, acquire a lock,
or call pthread_getspecific.

The runtime keeps a linked list only for GC reachability and owner teardown. It
is allocation order, not an LRU or lookup structure. On first touch,
runtime.LocalPackage allocates and roots the package block, then fills the TLS
cache. On exit, the runtime walks the root list once, clears each recorded cache
slot, and severs links. Escaped variable addresses remain valid without
retaining unrelated blocks.

native TLS                         outer Go entry stack
  p.__llgo_local_cache uintptr     LocalContext.blocks --------+
                                                               |
GC allocation                                                  |
  [padding][next payload 8 B][cacheSlot 8 B][p payload] <-------+

The TLS cache is a non-root uintptr; LocalContext.blocks is the stack GC
root. Each 64-bit block header remains 16 bytes.

Final macOS arm64 machine code confirms AlwaysInline is effective:
__llgo_local_block is eliminated, the cache check appears in the caller, and
only the miss branch calls runtime.LocalPackage.

Compilation and entry flow

  • internal/directive parses directives generically.
  • internal/locality owns declaration validation and initializer preparation.
  • internal/locality/layout is a pure type/layout planner.
  • ssa owns program-wide metadata and local-context/accessor code generation.
  • cl/locality_lower.go integrates the plan into normal variable lowering.
  • internal/build preloads source metadata before cached package compilation
    and fingerprints local-context entry mode.

Each package defines its own cache/accessor, TLS globals, initializer guards,
dispatcher, and ensure functions before body lowering. Package bases and
successful initializer checks are reused along SSA dominator paths.

The generated main entry installs a LocalContext before package initialization.
Each pthread wrapper generated for go f(...) installs a fresh context, and
C-exported Go entries use the same candidate enter/leave sequence. Nested entries
reuse an active context.

flowchart LR
    A[Source prepass] --> B[Initializer and layout plan]
    B --> C[Compile or load package modules]
    C --> D[Package TLS, cache, accessor, guards]
    B --> E[Instrument main, goroutine, exported entries]
    D --> F[Link runtime, packages, main]
    E --> F
Loading

The compiler does not infer support from target names or introduce a new target
tag. It emits LLVM TLS when selected; runtime source constraints continue to
choose hosted versus bare-metal runtime implementations.

Initialization

Explicit local variable initializers execute normally for the initial main
owner. A new owner replays them lazily on first access to that package/locality
kind:

//llgo:gls
var a = 1          // assign 1 per owner

//llgo:gls
var b = makeB()    // call makeB once per owner

//llgo:gls
var c, d = pair()  // call pair once per owner

//llgo:gls
var x T            // zero value, no helper

There is one dispatcher and one guard per package/locality kind, not per
variable. Dispatchers preserve Go initialization order. Recursive access sees
partial initialization. A failed initializer stays failed and re-panics with
the saved value, including panic(nil); failure payload storage is allocated
only on panic. Go func init() is never replayed.

Cost model

64-bit condition Cost
context-enabled outer Go entry one 8-byte candidate stack slot
package with GC-visible locality one 8-byte native TLS cache per pthread
untouched package payload no GC allocation
touched package payload 16-byte header + alignment padding + payload + allocator rounding
package/kind with explicit initializers one guard byte + one 8-byte failure-cache slot
successful initializer no failure block
failed initializer one lazy any block

Programs containing only zero-initialized pointer-free native TLS locals need no
LocalContext. Cold first-touch allocation is deliberately left out of this
optimization.

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.

Comparable imported //go:noinline func() uintptr readers:

Read path Previous list median Direct-cache median Current range
ordinary global ~0.778 ns/op 0.770 ns/op 0.7655-0.7774
native LLVM TLS 1.300 ns/op 1.300 ns/op 1.295-1.307
hot GLS package payload 1.561 ns/op 1.418 ns/op 1.390-1.422

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

All heavy local work used low parallelism; Ubuntu containers were capped at 2
CPU and 15 GiB.

  • macOS arm64, Go 1.26.5, LLVM 19.1.7:
    • full ssa passes;
    • focused compiler locality and cl/_testgo/localitycodegen pass;
    • real-LLGo test/llgoext and test/llgoext/localitymulti pass;
    • final disassembly verifies the inlined direct-cache hit path.
  • Ubuntu ARM64, Go 1.24.2, LLVM 19.1.7:
    • both real-LLGo test packages pass.
  • Ubuntu AMD64 container:
    • focused internal/locality/..., ssa, and cl pass;
    • root real-LLGo locality tests pass;
    • QEMU tool processes were unstable while compiling the separate multi-package
      suite, so native AMD64 CI is authoritative for that architecture.
  • internal/locality and internal/locality/layout each have 100% statement
    coverage.
  • Local patch mapping hits all 63 instrumented changed production lines, with
    zero uncovered or partial lines. Runtime is a nested module and is exercised
    by real-LLGo integration tests.
  • No existing test is skipped or ignored.

A full macOS cl run also completed. Its Go 1.26.5 failures are existing
floating/complex output-golden differences and do not involve locality code.
The ARM64 Ubuntu full package run likewise exposed existing architecture-specific
FileCheck alloca align 1 expectations; focused locality and runtime suites are
green.

Independent CI follow-up

Ubuntu coverage exposed a pre-existing runtime.FuncForPC cache publication
race. A cache entry previously published pc and *Func through separate
shared fields, so concurrent replacement could return a function for another
PC. The dedicated final PR commit publishes one immutable *Func atomically
and validates fn.pc; its stress probe passes on macOS and Ubuntu. This fix is
independent of the locality architecture and remains a separate commit.

Follow-up

This PR is self-contained. #2090 is the GLS-based runtime-state consumer and
will be rebased after this PR is green.

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cpunion
cpunion force-pushed the codex/threadlocal-getg branch from 4d83bb5 to 319d298 Compare July 13, 2026 09:54
@cpunion
cpunion changed the base branch from codex/runtime-g-state to main July 13, 2026 09:55
@cpunion
cpunion force-pushed the codex/threadlocal-getg branch 2 times, most recently from a811aba to f2d92a0 Compare July 13, 2026 10:26
@cpunion
cpunion force-pushed the codex/threadlocal-getg branch 3 times, most recently from 91078ba to ec4ea02 Compare July 13, 2026 11:44
@cpunion
cpunion marked this pull request as ready for review July 13, 2026 11:48
@cpunion
cpunion force-pushed the codex/threadlocal-getg branch from ec4ea02 to ec34002 Compare July 13, 2026 13:07
@cpunion
cpunion force-pushed the codex/threadlocal-getg branch from ec34002 to 973b556 Compare July 13, 2026 17:52
@cpunion cpunion changed the title compiler/runtime: add llgo:tls and llgo:gls variable storage compiler/runtime: add //llgo:tls and //llgo:gls package variables Jul 13, 2026
@cpunion
cpunion force-pushed the codex/threadlocal-getg branch from 973b556 to cdde104 Compare July 13, 2026 23:00
@cpunion cpunion changed the title compiler/runtime: add //llgo:tls and //llgo:gls package variables compiler/runtime: add sparse //llgo:tls and //llgo:gls variables Jul 13, 2026
@cpunion
cpunion marked this pull request as draft July 14, 2026 11:10
@cpunion cpunion changed the title compiler/runtime: add sparse //llgo:tls and //llgo:gls variables compiler/runtime: add //llgo:tls and //llgo:gls package variables Jul 14, 2026
@cpunion
cpunion force-pushed the codex/threadlocal-getg branch from 7e1681b to d626fca Compare July 14, 2026 11:13
@cpunion
cpunion marked this pull request as ready for review July 14, 2026 11:57
@cpunion
cpunion force-pushed the codex/threadlocal-getg branch from d626fca to c83695a Compare July 14, 2026 11:59
@cpunion
cpunion force-pushed the codex/threadlocal-getg branch 4 times, most recently from 61a919f to 1cdbd2b Compare July 15, 2026 13:23
cpunion added 12 commits July 20, 2026 12:09
Store each immutable Func as one atomic pointer and validate the queried PC from the Func itself. This prevents concurrent cache replacement from combining a PC and function from different writers. Apply the same publication rule to the prebuilt function cache and stress multiple PCs during concurrent first use.
Give each package-backed locality block an owner-local TLS cache so hot accesses are O(1) regardless of the number of touched packages. Keep the block list only for GC reachability and teardown.

Require locality variables to be unexported and reject go:linkname aliases. Add codegen, lifecycle, and 2/4/8-package performance coverage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

llgo-only Features that llgo supports but Go does not

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants