Skip to content

analysis: evaluate liveness-aware Full LTO pooling for Go strings #2188

Description

@cpunion

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:

  1. When an LTO pass plugin is configured, createGlobalStr marks its private
    byte globals with .llgo.str. Builds without a plugin are unchanged.
  2. 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

  1. Keep this as an opt-in Full LTO optimization initially; do not change the
    Go string ABI or normal non-LTO builds.
  2. Pool only producers that explicitly identify ordinary Go {ptr, len}
    backing bytes. This naturally includes current reflect/ABI strings.
  3. Keep funcinfo/pclntab and object-format string tables independent.
  4. Add Linux amd64 coverage and a default embedded-pclntab matrix before
    production enablement.
  5. Track full kubectl Full LTO memory as a separate prerequisite for large
    real-world applications.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions