Skip to content

Store union values without padding (32-bit support, phase 4a) - #337

Merged
ASDAlexander77 merged 5 commits into
mainfrom
phase4a-union-byte-storage
Sep 21, 2026
Merged

ASDAlexander77 merged 5 commits into
mainfrom
phase4a-union-byte-storage

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Summary

Phase 4a of 32-bit support: a tagged union now reads back every member's fields exactly, on x64 and on i686. This fixes a miscompile that has been in x64 all along. The plan is docs/superpowers/plans/2026-09-22-32-bit-phase-4a-union-byte-storage.md.

The defect

A tagged union lowered to a packed {ptr tag, S}, where S was the LLVM struct of its largest member. Members go in and out through memory, but any bytes that fall in S's padding are not part of the value once it is loaded as S. So a smaller member's field stored there was lost the next time the union was copied.

  • x64: in {flag; a: s32; b: s32} | {flag; v: number}, a read back as 0 (gc) or garbage (rc, none). 00bigint_struct_layout could not assert it.
  • i686: a pointer followed by an 8-aligned f64 hits this in common shapes. 02union_type printed Error 9.11037e-305 downloading instead of Error 1 downloading.

The change

  • Storage without padding. The value field is now packed <{[N/P x iP], [N%P x i8]}>: pointer-sized words plus a byte tail, where P is the pointer size in bytes. N is the old alloc size, so no union changes size. Words rather than bytes, because LLVM flattens a first-class [N x i8] argument into N separate byte arguments. LLVMTypeConverterHelper::getUnionStorageSize/getUnionStorageType.
  • Every reader of the union value was audited. Three changed:
    • the union→union cast, and castToAny of a union: both used the largest member's type as the value type;
    • castLLVMTypes: aggregates of 8 bytes or less went through a LoadSaveOp that moves exactly one pointer, which is 4 bytes at i686. That shortcut is removed; the constant-size memcpy is folded anyway.
  • Unused tail bytes are zeroed when a smaller member goes into a union. Before, they were undefined: at -O0, any === any of two identical values boxed from a union could be false, and the conservative GC saw garbage words.
  • Module-level unions initialized with a constant use a global constructor. Building a union now goes through memory, which is not allowed in a global initializer. Before this change, a non-largest member already failed with ops with side effects not allowed in global initializers; now let g: number | string = "x" compiles as well. Only tagged-union globals move to a constructor. Non-tagged unions (string | undefined, literal unions) are untouched.
  • The types have different sizes warning no longer fires for member-into-union conversions, which differ in size by design.

x64

x64 output changes for every tagged union, by design. x64 .text over the corpus (gc, --opt_level=3) changes by −0.03% (340240 → 340129 bytes). Exported functions that take or return unions change ABI: default-lib or DLL binaries built by the previous compiler must be rebuilt.

New: tslang/test/probe-corpus.sh

Compiles and runs every corpus file as a single-file exe with the suite's flags, for x64 and i686 × gc/rc/none, and writes a TSV. --compare before.tsv after.tsv lists every row that passed before and does not pass after. It is hand-run, like the other check-*.sh scripts. About 100 files per model fail at x64 too; those are multi-file tests that need the test runner.

pass counts x64 before → after i686 before → after
gc 423 → 425 410 → 417
rc 418 → 420 409 → 418
none 418 → 420 411 → 418

The file count goes from 520 to 522, because of the two new tests. --compare finds 0 regressions and 29 newly passing rows. At i686 the newly passing files are 02union_type, 03union_type, 03union_type_case_order, 00typeof_static_fold_conditions, parser, and arrayLiterals under rc.

Still failing at i686 only:

  • the six async files, for phase 4b;
  • internals, whose inline_asm<i64> with =r fits no single i686 register;
  • 02funcs_vararg, which is out of the suite.

Found, not fixed

  • An importer's module-level global initialized from a DLL-exported global reads garbage. For example, const copiedN: number = cN prints a denormal, and a union copy crashes silently. The old compiler does the same, and it happens at x64 too. It is recorded in the spec's open issues.
  • 00array8_tuple_spread and arrayLiterals2ES5 at i686 under rc flipped between two probe runs of the same compiler before this change.

Test plan

  • Windows Release suite: ctest -j 16 -C Release passes 2769/2769. That includes the new 00union_member_padding and 00union_global_init (compile and jit), and 00bigint_struct_layout with a asserted again.
  • The new and the existing union tests pass at x64 and i686 under gc, rc and none. 00union_global_init also passes with --di --opt_level=0 and with --emit=jit.
  • probe-corpus.sh --compare: 0 regressions against the base-commit baseline at either arch.
  • --verify-ownership -mm=rc gives output identical to the base compiler on 62 union corpus files at both arches. The one exception is 00union_global_init, which the base compiler cannot compile. The verifier runs before LowerToLLVM.
  • Two independent reviews. The first found the global-initializer regression and the per-byte argument cost; both are fixed here. The second approved the fixes.
  • Linux CI. Not run locally.

🤖 Generated with Claude Code

ASDAlexander77 and others added 5 commits September 21, 2026 18:24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add tslang/test/probe-corpus.sh, which compiles and runs every file in
the tester corpus as a single-file exe for x64 and i686 under gc, rc
and none, and writes a TSV. Its --compare mode lists every row that
passed in a baseline and does not pass now.

Add 00union_member_padding, whose union members have fields that sit
in another member's padding (at x64 and at i686). Re-arm the `a` assert
in 00bigint_struct_layout, which fails for the same reason.

The suite is red on these two tests until union storage becomes a byte
array (Phase 4a, Task 2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tagged union lowered to a packed { tag, S }, where S was the LLVM type
of its largest member. A value copy of a struct does not carry its
padding, so a smaller member's field that sat in S's padding was lost
whenever the union was copied as a value. At x64 this hit only some
layouts (00bigint_struct_layout could not assert its field `a`); at i686,
where a pointer is 4 bytes and an f64 after it is 8-aligned, it broke
ordinary code: 02union_type printed "Error 9.11037e-305 downloading".

S is now [N x i8], N being the largest member's alloc size, so the union
keeps its size and every byte is carried. Members still go in and out
through memory. Readers audited and fixed:

- union -> union casts and union -> any boxing extracted the value as
  the largest member's type, dropping the same padding; they now carry
  the source's byte storage.
- castLLVMTypes' through-memory aggregate path now accepts arrays as
  well as structs.
- that path used LoadSaveOp, which moves one pointer-sized value, for
  aggregates up to 8 bytes; at i686 that dropped the value of an 8-byte
  { tag, [4 x i8] } union. The limit is now the pointer size (unchanged
  at x64).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Global initializers: a tagged union's value goes in and out through
  memory, which an llvm.mlir.global initializer cannot hold, so a
  module-level or class-static union initialized with a constant failed
  with "ops with side effects not allowed in global initializers". A
  CreateUnionInstance, a GetValueFromUnion, or a cast to or from a tagged
  union now makes the global use a global constructor. New test
  00union_global_init.
- Storage is packed <{ [N / P x iP], [N % P x i8] }> (P = pointer size)
  instead of [N x i8]: LLVM passes an array argument element by element,
  so a byte array turned a union argument into N byte arguments.
- castLLVMTypes always copies aggregates with CopyStructOp. LoadSaveOp
  moved exactly one pointer's worth of bytes, wrong for anything smaller.
- When the destination is larger than the source, its tail is zeroed
  before the copy. Boxed into `any`, a union's storage is compared
  bytewise, and stale bytes made equal numbers compare unequal (i686 -O0).
- The array case added to castLLVMTypes is reverted; nothing reaches it.
- The "types have different sizes" warning is silenced for a member going
  into or out of a union, where the sizes differ by design.
- Stale findMaxSizeType references removed from a comment and dead code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 4 is split into 4a (union byte storage, done), 4b (async) and 4c (the suite at i686). Records the remaining i686-only probe failures and a pre-existing DLL-global init bug found in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit 9a521c3 into main Sep 21, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the phase4a-union-byte-storage branch September 21, 2026 19:21
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.

1 participant