Skip to content

Reduce static TLS from 856 to 16 bytes with a heap-allocated per-thread struct - #58

Merged
devmotion merged 7 commits into
masterfrom
dmw/tls_pointer
Aug 3, 2026
Merged

Reduce static TLS from 856 to 16 bytes with a heap-allocated per-thread struct#58
devmotion merged 7 commits into
masterfrom
dmw/tls_pointer

Conversation

@devmotion

Copy link
Copy Markdown
Member

Follow-up to #57. Fixes #56.

PR #50 made the generators' setup caches _Thread_local to fix data races, which gave libRmath-julia an 856-byte static TLS segment. glibc draws a dlopen'd module's TLS block from a fixed ~1664-byte process-wide surplus shared with every other shared library, so consuming half of it made unrelated libraries fail to load:

ERROR: InitError: could not load library "libgomp.so.1"
libgomp.so.1: cannot allocate memory in static TLS block

The glibc.rtld.optional_static_tls tunable that would raise the surplus only exists from glibc 2.32, so it does not help the affected users (#56 reports glibc 2.27).

Approach

Move the state to the heap, in one Rmath_tls container reached through a single thread-local pointer. 856 → 16 bytes: that pointer, plus sunif.c's seed.

Each generator keeps its own struct, nested in one container so there is one allocation, one free and one cleanup registration. Field names are unchanged from upstream so re-applying patches/thread-local.patch after make update stays mechanical.

sunif.c's I1/I2 stay _Thread_local deliberately: 8 bytes is exactly the size of the pointer that would replace it (zero gain), it is the hottest path in the library, and set_seed() returns void so it could not report an allocation failure.

Every field moved was confirmed to be written — by const-qualifying each one and checking the compiler rejects the assignment. All 74 qualify; the read-only coefficient tables stay const static, per the rule #57 added to the README.

Cleanup is automatic, with no new public API

include/Rmath.h is untouched. src/rmath_tls.c registers a thread-exit destructor via pthread_key_create() on POSIX and FlsAlloc() on Windows.

Windows avoids pthreads deliberately: on mingw-w64 -lpthread resolves through winpthreads and adds a runtime dependency on libwinpthread-1.dll, which Rmath_jll does not ship (its Yggdrasil recipe declares no dependencies) — the same class of load failure this PR fixes. On every Unix target -lpthread is free: libpthread.so.0 is part of glibc itself (folded into libc from 2.34), musl's is an empty stub, macOS has it in libSystem and FreeBSD in base.

Two pre-existing bugs fixed along the way

  • wilcox_free() never freed anything for m,n ≤ 50. It routed through a helper guarded on m > WILCOX_MAX || n > WILCOX_MAX, but w_init_maybe() floors both allocated_* at WILCOX_MAX, so the test never fired. Harmless upstream where the table is a process-wide cache; a ~25 kB per-thread leak since Improve thread-safety #50 made it _Thread_local.
  • src/Makefile had no header dependencies. Editing a header rebuilt nothing. Survivable while the headers were stable, but rmath_tls.h is included by seven .c files and declares Rmath_tls_ptr as _Thread_local: a partial rebuild links objects that disagree about whether to reach it through TLS, which links fine and then segfaults on the first call. Found the hard way on this branch.

Verification

  • RNG stream byte-identical to pre-refactor: 288 000 variates over a parameter schedule covering every algorithm branch, with repeats and alternations to exercise both cache hit and invalidation.
  • d/p/q signrank/wilcox byte-identical, including through the reallocation paths and the public *_free() calls.
  • Cleanup measured: *_free() releases 24 624 of the 25 536 bytes allocated; heap stays flat across 2000 thread create/join cycles, against 49.7 MB leaked with the destructor disabled.
  • make update round-trip: the regenerated patch applies to pristine R 4.4.1 sources and reproduces the working tree byte-for-byte in all eight files.
  • Not slower. Loads per object dropped 40–61% and TLV relocations went from 36–100 to 2. On Darwin every _Thread_local access is a thunk call, so rhyper went from 23 per invocation to one, measuring 30–38% faster. Expect roughly neutral on Linux, where GCC already hoists one __tls_get_addr per function.

Notes for review

  • The container is 856 bytes with zero padding waste — the same bytes as before, moved from a rationed pool to an unrationed one. It is also now lazy: a thread that never calls these generators pays nothing, where before every thread in the process paid eagerly.
  • rhyper reuses the cached s as a scratch temporary (s = ym / yn in branch III's large case), so that slot never needed caching. The aliasing is preserved exactly rather than "fixed", to keep the stream identical.
  • The new test.jl testset was verified to fail when the container pointer is made process-wide. CI now runs with four threads; it was single-threaded, so the pre-existing threaded testset never actually exercised concurrency.
  • No leak test for the thread-exit destructor: Julia pools its threads, so none ever exits and the destructor never fires. That path was verified with a C driver instead.

🤖 Generated with Claude Code

@nalimilan nalimilan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impressive!

Comment thread src/rbeta.c Outdated

#define v_w_from__u1_bet(AA) \
v = beta * log(u1 / (1.0 - u1)); \
v = st->beta * log(u1 / (1.0 - u1));\

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about doing beta = st->beta after loading st? That would reduce the diff size and reduce the risk of conflicts when updating Rmath version.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying in doesn't quite work here: these are a cache that survives across calls (keyed on olda/oldb), so without a copy-back before every return qsame would just serve stale coefficients. rhyper and rpois have a lot of exits and I would rather not depend on getting that right.

So instead I aliased the fields back to the upstream names at the fetch site:

    struct rbeta_state *st = &Rmath_tls_get()->rbeta;
#define beta	st->beta
#define olda	st->olda

with #undefs after the function. The bodies are byte-identical to R's now, which helps more than copy-in would have: upstream lines removed by the patch went 245 -> 38 (rhyper.c: 92 -> 10), and they are all in the declaration block, so upstream algorithm changes can no longer conflict. Patch went 1447 -> 804 lines.

If a future R version adds a local with a colliding name, the alias expands into it and it fails to compile at that line, which seems like the right failure mode. Noted in the README.

Does not work for signrank.c/wilcox.c since the state is shared with w_init_maybe/csignrank/cwilcox and has to be passed as a parameter there.

Stream is byte-identical (288k variates over all algorithm branches, clang and gcc-15).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. Is it really useful to call #undef at the end of the file though? For .c file (which are not #included anywhere), does it make any difference?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, they're at EOF in every one of those files so they guard nothing — dropped.

Comment thread src/rmath_tls.c Outdated
Comment thread src/rmath_tls.c Outdated
Comment thread src/rmath_tls.h Outdated
Comment thread README.md Outdated
Comment thread patches/thread-local.patch Outdated
@nalimilan
nalimilan requested a review from ViralBShah July 30, 2026 20:58
Base automatically changed from dmw/tls to master July 30, 2026 21:43
devmotion added a commit that referenced this pull request Jul 31, 2026
Review feedback from @nalimilan on #58.

Alias the per-thread state back to upstream's identifiers with a block of
`#define`s at each fetch site, instead of rewriting every use as `st->field`.
The generator bodies are now byte-identical to R's, so the upstream lines the
patch removes drop from 245 to 38 and an R release that edits an algorithm no
longer conflicts. A future release adding a local that collides with an alias
fails to compile at that line rather than changing behaviour silently.

Report a failed container allocation with MATHLIB_ERROR, as signrank.c and
wilcox.c already do for their tables. ML_WARN_return_NAN was not merely
weaker, it was silent: ML_WARNING is gated on `x > ME_DOMAIN`, so with
ME_DOMAIN it expands to nothing. Rmath_tls_get() can no longer return NULL,
which removes the error path from all seven call sites.

Drop src/sunif.c from patches/thread-local.patch. R ships it under
src/nmath/standalone/, which `make update` excludes, so the hunk was re-applied
to an already-patched file -- `patch` reported "Reversed (or previously applied)
patch detected" and non-interactively assumed -R, undoing the _Thread_local on
the seed. Document the rule in the README.

Assert the static TLS segment size in CI, which the README already claimed.

Also: spaces not tabs and NULL not 0 in rmath_tls.c; correct the claim that the
read-only coefficient tables are `const static` (fact[] in rpois.c is, the
rexpm1() coefficients in toms708.c are plain `static`).

Verified: RNG stream over 288000 variates and d/p/q signrank+wilcox are
byte-identical to master under both clang and gcc-15; heap flat across 2000
thread create/join cycles; the allocation-failure path exits 1 under an
interposed failing calloc; the patch applies with no prompts and reproduces
the tree byte-for-byte in all 128 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@devmotion

devmotion commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Pushed 9f0ecb5. Two things in there that you did not ask for:

make update was broken, and would have silently undone part of #50. patches/thread-local.patch had a hunk for src/sunif.c, but R ships that file under src/nmath/standalone/, which the extraction in the update target excludes. So the hunk was re-applied to a file that had never been reset:

patching file src/sunif.c
Reversed (or previously applied) patch detected!  Assume -R? [y]

Non-interactively patch answers [y], i.e. it reverses the hunk and strips _Thread_local off I1/I2, putting the seed data race back. Dropped the hunk (sunif.c keeps the qualifier as committed) and wrote the rule down in the README, since the trap is easy to walk back into when regenerating the patch.

CI now asserts the static TLS size, which the README claimed but nothing enforced. readelf on the Linux job, budget 64 bytes against the actual 16. Given that the whole problem in #56 is that growth here breaks other libraries, a regression should not have to be noticed by a downstream user.

Verification, all re-run after the rewrite:

  • RNG stream over 288 000 variates and d/p/q signrank/wilcox byte-identical to master under both clang and gcc-15. (The two compilers do not agree with each other on arm64 — FP contraction — but each agrees with master.)
  • Heap flat across 2000 thread create/join cycles; *_free() still releases 24 624 of 25 536 bytes.
  • Allocation failure exits 1 with the message, under an interposed failing calloc.
  • Patch applies with no prompts and reproduces the tree byte-for-byte in all 128 files of src/.
  • julia --threads=4 test.jl passes.

@devmotion
devmotion marked this pull request as ready for review July 31, 2026 13:22
devmotion and others added 6 commits July 31, 2026 15:23
PR #50 made the generators' setup caches _Thread_local to fix data races
between threads, which gave libRmath-julia an 856-byte static TLS segment.
glibc draws a dlopen'd module's TLS block from a fixed ~1664-byte
process-wide surplus shared with every other shared library, so consuming
half of it made unrelated libraries fail to load with "cannot allocate
memory in static TLS block" (issue #56).

Move the state to the heap behind a single thread-local pointer. Each
generator keeps its own struct, nested in one Rmath_tls container so there
is one allocation, one free and one cleanup registration. Field names are
unchanged from upstream R to keep patches/thread-local.patch mechanical.

Cleanup is automatic and there is no new public API: rmath_tls.c registers
a thread-exit destructor via pthread_key_create() on POSIX and FlsAlloc()
on Windows. Windows deliberately avoids pthreads, which on mingw-w64 would
add a runtime dependency on libwinpthread-1.dll that Rmath_jll does not
ship -- the same class of load failure this change fixes.

Each struct is initialised by a hook in its owning .c file, transcribing
the initialisers deleted from the declarations, so a future R release that
changes a sentinel shows both halves in one patch hunk.

Verified: the generated stream is byte-identical over 288k variates across
all algorithm branches, and TLS storage drops from 832 to 48 bytes
(macOS __thread_data + __thread_bss; 77 thread-local variables down to 8).
Every field moved was confirmed to be written by const-qualifying it and
checking the compiler rejects the assignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These two differ from the generators: the state is a pointer to a table
that is itself on the heap, so the container now owns a second level of
allocation. Rmath_signrank_state_free() and Rmath_wilcox_state_free() keep
the deep-free logic in the file that knows the table's shape -- wilcox's is
a jagged three-level array -- rather than in rmath_tls.c, and the thread-exit
destructor releases the tables before the container.

Neither gets an init hook. They had no initialisers upstream and they depend
on calloc()'s zeroing: a NULL w means "not allocated yet", and csignrank()
uses w[0] == 1. as its "table already built" flag.

This also fixes a per-thread leak. PR #50 made these _Thread_local, which
turned what upstream R treats as a deliberate process-wide cache into
per-thread tables -- for wilcox, 51x51 pointers (~21 KB) plus every row
cached on demand -- with no automatic release. Thread exit now frees them.

Static TLS storage is now 16 bytes, down from 856: the Rmath_tls pointer
plus sunif.c's seed, which stays _Thread_local because it is exactly the
size of the pointer that would replace it, sits on the hottest path, and is
reached through a void set_seed() that could not report an allocation
failure.

Verified: the RNG stream is unchanged, and d/p/q signrank and wilcox are
byte-identical across a schedule that straddles WILCOX_MAX, revisits smaller
sizes to exercise the reallocation paths, and calls the public free
functions mid-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wilcox_free() routed through a w_free_maybe() helper that only freed when m
or n exceeded WILCOX_MAX (50). But w_init_maybe() floors both allocated_m
and allocated_n at WILCOX_MAX, so for the usual m,n <= 50 the condition
never held and the public wilcox_free() silently did nothing.

That is reasonable upstream, where w[][][] is a process-wide cache worth
keeping. Since PR #50 made it _Thread_local it is per-thread, so a caller
asking for it to be freed should get it freed. Call the deep-free directly;
the helper had no other caller and is removed.

Measured: dwilcox(5, 10, 10) plus dsignrank(5, 20) allocate 25536 bytes, of
which the two free functions now release 24624. Results are unaffected --
the table transparently rebuilds -- and d/p/q values remain byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The %.o: %.c rule had no header prerequisites, so editing a header rebuilt
nothing. That was survivable while the headers were stable and `make update`
overwrote whole files, but rmath_tls.h is included by seven .c files and
declares Rmath_tls_ptr as _Thread_local: a partial rebuild links objects that
disagree about whether to reach it through TLS, which links fine and then
segfaults on the first call. Found the hard way while testing this branch.

Generate .d files with -MMD -MP and include them. Touching rmath_tls.h now
rebuilds its eight dependents, and nmath.h rebuilds 119.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerate patches/thread-local.patch against pristine R 4.4.1 sources and
verify the round trip: applying it to the extracted nmath sources reproduces
the working tree byte-for-byte in all eight files.

Give the README a "Per-thread state" section explaining why the state is on
the heap, and the two rules for touching it: new mutable per-thread state
goes in Rmath_tls with an init hook in the owning .c file, and read-only
tables stay const static. Records the const-qualifier trick for telling the
two apart, including clang's 20-diagnostic default that hides some.

Add a test that separate threads with separate parameters reproduce what each
would draw alone, using the library's own seeded generator since Julia's rand
is shared across threads. Verified to fail when the container pointer is made
process-wide. CI now runs with four threads; it was single-threaded, so the
pre-existing threaded testset never actually exercised concurrency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback from @nalimilan on #58.

Alias the per-thread state back to upstream's identifiers with a block of
`#define`s at each fetch site, instead of rewriting every use as `st->field`.
The generator bodies are now byte-identical to R's, so the upstream lines the
patch removes drop from 245 to 38 and an R release that edits an algorithm no
longer conflicts. A future release adding a local that collides with an alias
fails to compile at that line rather than changing behaviour silently.

Report a failed container allocation with MATHLIB_ERROR, as signrank.c and
wilcox.c already do for their tables. ML_WARN_return_NAN was not merely
weaker, it was silent: ML_WARNING is gated on `x > ME_DOMAIN`, so with
ME_DOMAIN it expands to nothing. Rmath_tls_get() can no longer return NULL,
which removes the error path from all seven call sites.

Drop src/sunif.c from patches/thread-local.patch. R ships it under
src/nmath/standalone/, which `make update` excludes, so the hunk was re-applied
to an already-patched file -- `patch` reported "Reversed (or previously applied)
patch detected" and non-interactively assumed -R, undoing the _Thread_local on
the seed. Document the rule in the README.

Assert the static TLS segment size in CI, which the README already claimed.

Also: spaces not tabs and NULL not 0 in rmath_tls.c; correct the claim that the
read-only coefficient tables are `const static` (fact[] in rpois.c is, the
rexpm1() coefficients in toms708.c are plain `static`).

Verified: RNG stream over 288000 variates and d/p/q signrank+wilcox are
byte-identical to master under both clang and gcc-15; heap flat across 2000
thread create/join cycles; the allocation-failure path exits 1 under an
interposed failing calloc; the patch applies with no prompts and reproduces
the tree byte-for-byte in all 128 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They sit at end-of-file in all five generators, so the aliases they undo are
already dead where they are undefined -- nothing is protected.  No .c file is
#included anywhere in src/ and there is no unity build, so there is no
cross-translation-unit leakage either.

The compile-error guarantee comes from the #define, not the #undef: a future R
release adding a local named `beta` inside rbeta() expands to `double
st->beta;` and fails to compile with or without them.  Code added *below* the
function would fail too, since `st` is not in scope there.

Meanwhile each block cost a patch hunk anchored on the last three lines of a
generator function (@@ -133,3 +149,11 @@ and friends), so an upstream edit to
the tail of rbeta()/rhyper()/... conflicted on `make update` for no benefit --
exactly the cost the aliasing is there to avoid.  Two hunks per generator now
instead of three, and 99 fewer lines of patch.

The five object files are byte-identical to before, as they must be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@devmotion
devmotion requested a review from nalimilan August 3, 2026 09:25
@devmotion
devmotion merged commit 982eb93 into master Aug 3, 2026
7 checks passed
@devmotion
devmotion deleted the dmw/tls_pointer branch August 3, 2026 12:55
imciner2 pushed a commit to JuliaPackaging/Yggdrasil that referenced this pull request Aug 3, 2026
Picks up JuliaStats/Rmath-julia#58, which moves the per-thread generator
state off static TLS onto the heap (TLS segment 856 -> 16 bytes) and fixes
JuliaStats/Rmath-julia#56.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

libRmath-julia.so's TLS segment exhausts static TLS on old glibc, breaking later library loads (e.g. libgomp)

2 participants