Summary
This issue records an experiment for sharing LLGo-generated Go string backing
bytes after whole-program liveness is known.
The result supports a Full LTO last-stage pool for ordinary Go strings and
reflect/ABI strings that use the same {ptr, len} representation. It does
not support replacing every compiler string table with one frontend-owned
pool: that loses whole-program DCE information and crosses formats with
different lookup and stripping rules.
Observed ELF reduction relative to the same LTO plugin with pooling disabled:
- JSON/HTTP program: 24,856 B (0.437%)
- string-heavy kubectl package closure: 108,632 B (1.038%)
The complete kubectl CLI currently exposes a separate Full LTO scalability
problem: the unpooled link is OOM-killed around 11.13 GiB in the constrained
Docker VM before the proposed string-pool pass runs.
Current storage boundaries
Tested from LLGo main at 6cc54167fe7ecf377482e390c89ee03a7d8e912c.
| Producer |
Current representation |
Existing sharing |
Pool decision |
| Go literals and constant strings |
private byte array referenced by a Go {ptr, len} value |
exact reuse inside one package through Package.strs |
eligible after Full LTO |
| reflect/ABI type, field, method, tag, and package-path strings |
Builder.Str -> the same createGlobalStr path |
same package-local reuse as literals |
eligible; no separate runtime ABI is needed |
| embedded/external funcinfo and pclntab names |
__llgo_funcinfo_strings$data, NUL-terminated table, uint32 IDs/offsets |
already exact-deduplicated and suffix-shared by internal/build/funcinfo.stringPool |
keep independent |
LLVM/object symbol names (.strtab, .dynstr) |
object-format/linker-owned tables |
linker-owned |
not eligible |
arbitrary createGlobalBytes data |
raw bytes with producer-specific semantics |
none guaranteed |
not eligible without producer opt-in |
The funcinfo/pclntab table should remain independent. Its consumers use stable
IDs and NUL-terminated offsets, and it can be omitted or externalized
independently. Physically merging it with Go {ptr, len} backing bytes would
couple unrelated formats and stripping modes.
Prototype
The prototype has two narrowly separated parts:
- When an LTO pass plugin is configured,
createGlobalStr marks its private
byte globals with .llgo.str. Builds without a plugin are unchanged.
- An LLVM module pass runs at
registerFullLinkTimeOptimizationLastEPCallback, after normal Full LTO
optimization and GlobalDCE:
- collect only surviving, constant, marked
[N x i8] globals;
- preserve arbitrary bytes, including embedded NUL;
- deduplicate exact byte strings;
- sort deterministically by decreasing length and then bytes;
- share suffixes and concatenate the remaining roots once;
- replace each old address with an in-bounds GEP into one private constant
array;
- erase the old globals and the marker section.
The pass uses the module DataLayout pointer index type. Pooling was
environment-gated for the experiment so marked/unpooled and pooled links used
the same frontend objects:
LLGO_LTO_STRING_POOL=0 # marked, unpooled baseline
LLGO_LTO_STRING_POOL=1 # pooled
The existing reflect-method LTO pass also runs whenever the plugin is loaded.
For that reason, the valid incremental comparison is marked versus pooled, not
no-plugin versus pooled.
Test environment
- Host: macOS arm64
- Linux test container: Ubuntu arm64, Go 1.26.5, LLVM/clang/lld 19.1.7
- LLGo:
6cc54167fe7ecf377482e390c89ee03a7d8e912c
- kubectl: v0.36.3,
73fdc7a337b55d4097e8723cb44d09ac7473e1d4
- Build mode:
-O2 -lto=full -globaldce -pclntab=none -ldflags=-w
- Resource policy:
GOMAXPROCS=2, GOFLAGS=-p=1, 2 CPU container quota,
15 GiB cgroup limit; Docker VM exposed 11.66 GiB
The kubectl staging repository needed -mod=mod to add omitted checksums for
metrics dependencies. The official Go compiler then built the full 895-package
CLI, and kubectl version --client --output=yaml passed.
Pool statistics
input is the sum of surviving marked globals. unique is after exact
deduplication. pool is after suffix sharing.
| Workload |
Go packages |
Globals |
Unique |
Roots |
Input B |
Unique B |
Pool B |
Exact saved B |
Suffix saved B |
| synthetic correctness fixture |
63 |
2,846 |
2,446 |
2,123 |
44,383 |
41,281 |
38,772 |
3,102 |
2,509 |
| JSON/HTTP |
187 |
12,419 |
10,450 |
9,012 |
264,889 |
249,154 |
234,980 |
15,735 |
14,174 |
| kubectl templates closure |
292 |
29,000 |
22,992 |
17,976 |
607,399 |
533,118 |
483,576 |
74,281 |
49,542 |
An earlier JSON/HTTP package-object scan, before whole-program liveness, found
28,232 physical globals / 523,585 B and 12,344 exact-unique strings /
297,239 B. The late pass sees only 12,419 live globals / 264,889 B. This is the
main reason to pool after Full LTO DCE rather than while compiling packages.
Final ELF results
| Workload |
Marked/unpooled B |
Pooled B |
Reduction B |
Reduction |
| synthetic fixture |
978,280 |
973,600 |
4,680 |
0.478% |
| JSON/HTTP |
5,690,976 |
5,666,120 |
24,856 |
0.437% |
| kubectl templates closure |
10,464,152 |
10,355,520 |
108,632 |
1.038% |
For the kubectl closure, the unpooled .llgo.str section was 599,367 B.
Pooling removed that section and added the 483,576 B pool to .rodata.
Section/layout overhead explains why the final ELF reduction differs slightly
from the byte-level pool calculation.
The successful kubectl comparison exercises the real
k8s.io/kubectl/pkg/util/templates implementation, Cobra, and Markdown
normalization. It is not a synthetic copy of kubectl behavior. Its Go,
marked-LLGo, and pooled-LLGo outputs had the same SHA-256:
757282bdd5322f1b580846119b9eb8647199e61c4c8f6e18d5ec68623802a3f4
Full LTO resource scaling
These runs used the same limits. All failed runs had pooling disabled, so the
new late pass was not the source of the peak.
| Target |
Packages |
Optimization |
Result |
cgroup peak |
| full kubectl CLI |
895 |
O2 |
OOM, exit 137 |
11.131 GiB |
| full kubectl CLI |
895 |
O0 |
OOM, exit 137 |
11.136 GiB |
| kubectl version closure |
707 |
O2 |
OOM, exit 137 |
11.141 GiB |
| kubectl templates closure, unpooled |
292 |
O2 |
pass |
3.687 GiB |
| kubectl templates closure, pooled |
292 |
O2 |
pass |
3.512 GiB |
The similar O0/O2 peaks indicate that materializing and linking the full
whole-program module dominates this limit. Full kubectl coverage needs a
separate LTO memory/scalability effort or a larger isolated linker VM; the
string-pool experiment should not weaken the resource cap.
Correctness and regression checks
- LLVM pass fixture covers exact duplicates, suffixes, embedded NUL, dead
marked data, and unmarked non-Go data.
- A real clang/lld Full LTO fixture proves the last callback sees one live
marked global after the dead marked global was removed by the preceding
pipeline.
- Synthetic no-plugin, marked, and pooled programs produced identical output;
the explicit dead marker was absent from every final ELF.
- JSON/HTTP marked and pooled programs both produced
POST /v1/items Validate 7.
- Go and LLGo kubectl closure output matched byte-for-byte.
go test ./ssa ./internal/lto -count=1 passed.
- Linux
opt, FileCheck, verifier, and clang/lld Full LTO tests passed.
- The pooled ELFs contain no
.llgo.str section.
Recommendation
- Keep this as an opt-in Full LTO optimization initially; do not change the
Go string ABI or normal non-LTO builds.
- Pool only producers that explicitly identify ordinary Go
{ptr, len}
backing bytes. This naturally includes current reflect/ABI strings.
- Keep funcinfo/pclntab and object-format string tables independent.
- Add Linux amd64 coverage and a default embedded-pclntab matrix before
production enablement.
- Track full kubectl Full LTO memory as a separate prerequisite for large
real-world applications.
- If the 0.4-1.0% measured ELF reduction is accepted as worthwhile, turn the
prototype into a focused PR with the pass tests and end-to-end LLGo test.
Summary
This issue records an experiment for sharing LLGo-generated Go string backing
bytes after whole-program liveness is known.
The result supports a Full LTO last-stage pool for ordinary Go strings and
reflect/ABI strings that use the same
{ptr, len}representation. It doesnot support replacing every compiler string table with one frontend-owned
pool: that loses whole-program DCE information and crosses formats with
different lookup and stripping rules.
Observed ELF reduction relative to the same LTO plugin with pooling disabled:
The complete kubectl CLI currently exposes a separate Full LTO scalability
problem: the unpooled link is OOM-killed around 11.13 GiB in the constrained
Docker VM before the proposed string-pool pass runs.
Current storage boundaries
Tested from LLGo main at
6cc54167fe7ecf377482e390c89ee03a7d8e912c.{ptr, len}valuePackage.strsBuilder.Str-> the samecreateGlobalStrpath__llgo_funcinfo_strings$data, NUL-terminated table,uint32IDs/offsetsinternal/build/funcinfo.stringPool.strtab,.dynstr)createGlobalBytesdataThe funcinfo/pclntab table should remain independent. Its consumers use stable
IDs and NUL-terminated offsets, and it can be omitted or externalized
independently. Physically merging it with Go
{ptr, len}backing bytes wouldcouple unrelated formats and stripping modes.
Prototype
The prototype has two narrowly separated parts:
createGlobalStrmarks its privatebyte globals with
.llgo.str. Builds without a plugin are unchanged.registerFullLinkTimeOptimizationLastEPCallback, after normal Full LTOoptimization and GlobalDCE:
[N x i8]globals;array;
The pass uses the module
DataLayoutpointer index type. Pooling wasenvironment-gated for the experiment so marked/unpooled and pooled links used
the same frontend objects:
The existing reflect-method LTO pass also runs whenever the plugin is loaded.
For that reason, the valid incremental comparison is marked versus pooled, not
no-plugin versus pooled.
Test environment
6cc54167fe7ecf377482e390c89ee03a7d8e912c73fdc7a337b55d4097e8723cb44d09ac7473e1d4-O2 -lto=full -globaldce -pclntab=none -ldflags=-wGOMAXPROCS=2,GOFLAGS=-p=1, 2 CPU container quota,15 GiB cgroup limit; Docker VM exposed 11.66 GiB
The kubectl staging repository needed
-mod=modto add omitted checksums formetrics dependencies. The official Go compiler then built the full 895-package
CLI, and
kubectl version --client --output=yamlpassed.Pool statistics
inputis the sum of surviving marked globals.uniqueis after exactdeduplication.
poolis after suffix sharing.An earlier JSON/HTTP package-object scan, before whole-program liveness, found
28,232 physical globals / 523,585 B and 12,344 exact-unique strings /
297,239 B. The late pass sees only 12,419 live globals / 264,889 B. This is the
main reason to pool after Full LTO DCE rather than while compiling packages.
Final ELF results
For the kubectl closure, the unpooled
.llgo.strsection was 599,367 B.Pooling removed that section and added the 483,576 B pool to
.rodata.Section/layout overhead explains why the final ELF reduction differs slightly
from the byte-level pool calculation.
The successful kubectl comparison exercises the real
k8s.io/kubectl/pkg/util/templatesimplementation, Cobra, and Markdownnormalization. It is not a synthetic copy of kubectl behavior. Its Go,
marked-LLGo, and pooled-LLGo outputs had the same SHA-256:
Full LTO resource scaling
These runs used the same limits. All failed runs had pooling disabled, so the
new late pass was not the source of the peak.
The similar O0/O2 peaks indicate that materializing and linking the full
whole-program module dominates this limit. Full kubectl coverage needs a
separate LTO memory/scalability effort or a larger isolated linker VM; the
string-pool experiment should not weaken the resource cap.
Correctness and regression checks
marked data, and unmarked non-Go data.
marked global after the dead marked global was removed by the preceding
pipeline.
the explicit dead marker was absent from every final ELF.
POST /v1/items Validate 7.go test ./ssa ./internal/lto -count=1passed.opt,FileCheck, verifier, and clang/lld Full LTO tests passed..llgo.strsection.Recommendation
Go string ABI or normal non-LTO builds.
{ptr, len}backing bytes. This naturally includes current reflect/ABI strings.
production enablement.
real-world applications.
prototype into a focused PR with the pass tests and end-to-end LLGo test.