Skip to content

Reduce frametable size#6399

Draft
NickBarnes wants to merge 12 commits into
oxcaml:mainfrom
NickBarnes:frametable-rearrange
Draft

Reduce frametable size#6399
NickBarnes wants to merge 12 commits into
oxcaml:mainfrom
NickBarnes:frametable-rearrange

Conversation

@NickBarnes

@NickBarnes NickBarnes commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Rework the native-code frame tables to save space. Six main steps:

  • Remove all alignment restrictions on frametable data;
  • Use the linker to de-duplicate debuginfo strings (filenames and defnames);
  • Introduce a "short" descriptor format (see the long comment in frame_descriptors.h);
  • Extend the short format to use a bitmap for scannable stack frame slots;
  • Allow return address deltas to cross function boundaries;
  • Share common suffixes of debuginfo chains.

Results on rodata section sizes:

step ocamlopt.opt (14.57 MiB) large executable (645.7 MiB)
Remove alignment -857.0 KiB (-5.74%) -33.98 MiB (-5.26%)
De-dupe strings -1.61 MiB (-11.71%) -69.09 MiB (-11.29%)
Short format -1.66 MiB (-13.71%) -86.21 MiB (-15.89%)
Frame bitmap -404.3 KiB (-3.77%) -24.47 MiB (-5.36%)
Cross-function deltas -7.7 KiB (-0.07%) -13.84 MiB (-3.20%)
Shared debuginfo suffixes -1.53 MiB (-15.22%) -33.19 MiB (-7.94%)
Total -6.04 MiB (-41.46%) -260.78 MiB (-40.38%)

Results on executable sizes:

step ocamlopt.opt (77.08 MiB) large executable (3.05 GiB)
Remove alignment -845.9 KiB (-1.07%) -33.97 MiB (-1.09%)
De-dupe strings -1.61 MiB (-2.11%) -69.08 MiB (-2.24%)
Short format -1.64 MiB (-2.19%) -86.20 MiB (-2.85%)
Frame bitmap -423.7 KiB (-0.57%) -24.47 MiB (-0.83%)
Cross-function deltas -7.8 KiB (-0.01%) -13.83 MiB (-0.48%)
Shared debuginfo suffixes -1.52 MiB (-2.10%) -33.18 MiB (-1.15%)
Total -6.02 MiB (-7.81%) -260.75 MiB (-8.35%)

Note: the "cross-function deltas" change has little effect if -ffunction-sections is given, as deltas can only be calculated within a single section.

The "short" format is implemented in the AMD64 and ARM64 backends, including the internal assembler/binary emitters. It is not implemented with the MASM backend (MASM lacks a way to emit ULEB128 constants). On MacOS we can't have the "cross-function delta" win, because the assembler doesn't regard cross-atom relative addresses to be constant, and every function starts a constant.

I also discovered that the dissector may include a lot of unreachable modules (and their frametables etc) in an executable. Fixing this may lead to large savings in dissected executable sizes, but is out-of-scope for this PR.

Design and analysis by me; code mostly written by Claude, although it required quite close oversight. It was very good at building quick tools to measure things ("how many debuginfo records use the full width of each bit field") but less good at (for example) designing data formats.

Commits

  1. Measure registered frametables (Xmeasure_frametables=1) — instrumentation: report the size and composition of the frametables registered with the runtime.
  2. Emit caml_all_frametables section; measure all frametables (Xmeasure_all_frametables=1) — instrumentation: the backend emits one pointer per unit into a linker-collected section, so the runtime can enumerate and measure every linked frametable, including ones that are never registered. ELF-only (elsewhere the measurement covers no frametables).
  3. Always emit frame descriptors in increasing address order — required by the short descriptor encoding's return-address deltas.
  4. Read frame descriptors at byte offsets, without C structs — treat a frame_descr * as an opaque byte pointer; read fields via caml_read_unaligned_* at explicit offsets, so descriptors need no alignment.
  5. Remove all alignment from emitted frame tables — descriptors are packed with no alignment or padding.
  6. De-duplicate debuginfo strings via a mergeable section — filename/defname strings are emitted once per unit and de-duplicated across compilation units by the linker.
  7. Introduce the compact ("short") frame-descriptor format — the new encoding itself (see runtime/caml/frame_descriptors.h). Includes the "frame bitmap" part (enumerated separately in the table above). Emitted by the amd64 and ARM64 backends, both via textual assembly and via the internal assembler / JIT binary emitters (MASM, which lacks .uleb128, escapes every descriptor). LLVM-backend compilations set the oxcaml_short_frametables module flag so the LLVM frametable printer emits compatible (escaped) descriptors. The measurement gains a breakdown of the reasons why some descriptors escape the short format.
  8. Chain frame-descriptor deltas across functions in a shared text section — the section epoch now bumps only on a genuine text-section change, so in the default shared-.text case the first descriptor of each function delta-encodes against the previous function's last descriptor instead of escaping. No effect under -function-sections (descriptors still escape at real section boundaries).
  9. Share suffixes of debuginfo chains — use a "jump word" for suffix-encoding of debuginfo chains which share a suffix.

@NickBarnes

Copy link
Copy Markdown
Contributor Author

Putting this into draft as I am having some success with the ARM64/MacOS/internal-assembler ports, so I think I'm going to aim to incorporate all the things currently listed as "future work".

@NickBarnes
NickBarnes force-pushed the frametable-rearrange branch 2 times, most recently from 7705cde to a06c2e7 Compare July 7, 2026 10:53
NickBarnes added a commit to NickBarnes/oxcaml that referenced this pull request Jul 7, 2026
Replace the unconditional per-function section-epoch bump with
Emitaux.enter_code_section, which bumps only when the text-section name
changes. In the default shared-.text case each unit forms one delta
chain, so a function's first descriptor delta-encodes against the
previous function's last instead of escaping (~8-13 bytes -> 2-5 per
boundary). Function-sections, basic-block-sections and startup-section
builds still escape at every genuine section change.

Note: binaries built with -function-sections (including ocamlopt.opt
itself) gain almost nothing, by design; the oxcaml#6399 estimate for this
item assumed shared-.text links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NickBarnes
NickBarnes force-pushed the frametable-rearrange branch from 213e680 to e3692c4 Compare July 7, 2026 13:07
NickBarnes added a commit to NickBarnes/oxcaml that referenced this pull request Jul 7, 2026
Replace the unconditional per-function section-epoch bump with
Emitaux.enter_code_section, which bumps only when the text-section name
changes. In the default shared-.text case each unit forms one delta
chain, so a function's first descriptor delta-encodes against the
previous function's last instead of escaping (~8-13 bytes -> 2-5 per
boundary). Function-sections, basic-block-sections and startup-section
builds still escape at every genuine section change.

Note: binaries built with -function-sections (including ocamlopt.opt
itself) gain almost nothing, by design; the oxcaml#6399 estimate for this
item assumed shared-.text links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NickBarnes
NickBarnes force-pushed the frametable-rearrange branch from e3692c4 to aae75d8 Compare July 7, 2026 21:32
NickBarnes added a commit to NickBarnes/oxcaml that referenced this pull request Jul 7, 2026
Replace the unconditional per-function section-epoch bump with
Emitaux.enter_code_section, which bumps only when the text-section name
changes. In the default shared-.text case each unit forms one delta
chain, so a function's first descriptor delta-encodes against the
previous function's last instead of escaping (~8-13 bytes -> 2-5 per
boundary). Function-sections, basic-block-sections and startup-section
builds still escape at every genuine section change.

Note: binaries built with -function-sections (including ocamlopt.opt
itself) gain almost nothing, by design; the oxcaml#6399 estimate for this
item assumed shared-.text links.
On Mach-O, function boundaries remain delta boundaries: the assembler
will not fold label differences across atoms (each non-private symbol
starts one), and .uleb128 has no relocated form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NickBarnes
NickBarnes force-pushed the frametable-rearrange branch from aae75d8 to c6379ea Compare July 7, 2026 21:39
NickBarnes added a commit to NickBarnes/oxcaml that referenced this pull request Jul 13, 2026
Replace the unconditional per-function section-epoch bump with
Emitaux.enter_code_section, which bumps only when the text-section name
changes. In the default shared-.text case each unit forms one delta
chain, so a function's first descriptor delta-encodes against the
previous function's last instead of escaping (~8-13 bytes -> 2-5 per
boundary). Function-sections, basic-block-sections and startup-section
builds still escape at every genuine section change.

Note: binaries built with -function-sections (including ocamlopt.opt
itself) gain almost nothing, by design; the oxcaml#6399 estimate for this
item assumed shared-.text links.
On Mach-O, function boundaries remain delta boundaries: the assembler
will not fold label differences across atoms (each non-private symbol
starts one), and .uleb128 has no relocated form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NickBarnes
NickBarnes force-pushed the frametable-rearrange branch from c6379ea to 696cb17 Compare July 13, 2026 14:08
NickBarnes added a commit to NickBarnes/oxcaml that referenced this pull request Jul 13, 2026
Replace the unconditional per-function section-epoch bump with
Emitaux.enter_code_section, which bumps only when the text-section name
changes. In the default shared-.text case each unit forms one delta
chain, so a function's first descriptor delta-encodes against the
previous function's last instead of escaping (~8-13 bytes -> 2-5 per
boundary). Function-sections, basic-block-sections and startup-section
builds still escape at every genuine section change.

Note: binaries built with -function-sections (including ocamlopt.opt
itself) gain almost nothing, by design; the oxcaml#6399 estimate for this
item assumed shared-.text links.
On Mach-O, function boundaries remain delta boundaries: the assembler
will not fold label differences across atoms (each non-private symbol
starts one), and .uleb128 has no relocated form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NickBarnes
NickBarnes marked this pull request as ready for review July 13, 2026 15:56
@NickBarnes

Copy link
Copy Markdown
Contributor Author

Back out of draft and ready for review @mshinwell @stedolan. Saving 40% of all rodata (8% of executable size) is pretty good, I think. The dwarf-tests CI run failure is fixed in the (separate) #6464. The llvmize-tests failure can be readily fixed with #37 and some small changes to the CI workflow.

NickBarnes and others added 3 commits July 14, 2026 09:29
Add an Xmeasure_frametables GC tweak (OCAMLRUNPARAM=Xmeasure_frametables=1)
that walks the frametables registered in each batch and reports descriptor,
header and debuginfo sizes together with register/slot statistics, including
a per-register usage count.
The runtime can register frametables lazily (e.g. for dynamically linked
units), so OCAMLRUNPARAM=Xmeasure_frametables only sees those registered so
far. Emit a per-unit pointer into a linker-collected caml_all_frametables
section (ELF only) and add an Xmeasure_all_frametables tweak that walks every
frametable linked into the executable, registered or not, reading only static
data. The hand-written runtime frametable contributes itself to the section.

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

The __start_/__stop_ section-encapsulation symbols are an ELF linker
feature; on other platforms (e.g. Mach-O) the measurement covers no
frametables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The compact frame-descriptor format will store each return address as a delta
from the previous one, which requires the descriptors in a frame table to be
ordered by increasing return address. Record them in that order: call frames
are recorded inline as they are emitted, and allocation/poll frames are
recorded when their out-of-line GC stub is emitted (where the return-address
label is about to be defined) rather than at the allocation site. The frame
descriptor list is built by prepending, so emit_frames reverses it.

Done for both AMD64 and ARM64.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NickBarnes added a commit to NickBarnes/oxcaml that referenced this pull request Jul 14, 2026
Replace the unconditional per-function section-epoch bump with
Emitaux.enter_code_section, which bumps only when the text-section name
changes. In the default shared-.text case each unit forms one delta
chain, so a function's first descriptor delta-encodes against the
previous function's last instead of escaping (~8-13 bytes -> 2-5 per
boundary). Function-sections, basic-block-sections and startup-section
builds still escape at every genuine section change.

Note: binaries built with -function-sections (including ocamlopt.opt
itself) gain almost nothing, by design; the oxcaml#6399 estimate for this
item assumed shared-.text links.
On Mach-O, function boundaries remain delta boundaries: the assembler
will not fold label differences across atoms (each non-private symbol
starts one), and .uleb128 has no relocated form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NickBarnes
NickBarnes force-pushed the frametable-rearrange branch from 696cb17 to 1992279 Compare July 14, 2026 08:53
@NickBarnes

Copy link
Copy Markdown
Contributor Author

Rebased to bring in #6464 and added two commits from #6413 to make the llvmize-tests CI work. The second one of these commits is strictly temporary, depending on @julesjacobs personal temporary release of llvm-project oxcaml-llvmize-16.0.6-minus3-pr37

@NickBarnes

Copy link
Copy Markdown
Contributor Author

Further work suggested by this:

  • inline debuginfo words into frame descriptors, instead of relative-addressed out-of-line. Some simple measurements suggest this would save ~1% of executable size.
  • use some sort of prefix encoding on the defname and filename strings in debuginfo.
  • use Xmeasure_all_frametables=1 to measure ARM64 hot registers, and use those rather than the set 0, 1, ..., 7.

NickBarnes added a commit to NickBarnes/oxcaml that referenced this pull request Jul 14, 2026
Replace the unconditional per-function section-epoch bump with
Emitaux.enter_code_section, which bumps only when the text-section name
changes. In the default shared-.text case each unit forms one delta
chain, so a function's first descriptor delta-encodes against the
previous function's last instead of escaping (~8-13 bytes -> 2-5 per
boundary). Function-sections, basic-block-sections and startup-section
builds still escape at every genuine section change.

Note: binaries built with -function-sections (including ocamlopt.opt
itself) gain almost nothing, by design; the oxcaml#6399 estimate for this
item assumed shared-.text links.
On Mach-O, function boundaries remain delta boundaries: the assembler
will not fold label differences across atoms (each non-private symbol
starts one), and .uleb128 has no relocated form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NickBarnes
NickBarnes force-pushed the frametable-rearrange branch from 1992279 to a0b91f4 Compare July 14, 2026 12:35
NickBarnes and others added 8 commits July 14, 2026 16:24
Frame tables will soon be packed without inter-descriptor alignment, so the
runtime must not assume frame descriptors are aligned. Treat a [frame_descr *]
as an opaque byte pointer and read each field at an explicit byte offset via
new caml_read_unaligned_* helpers, instead of through the frame_descr /
frame_descr_long C structs (which are removed). Forming an aligned-typed
pointer to unaligned data would be undefined behaviour.

No on-disk format change yet: descriptors are still emitted aligned; only the
way the runtime reads them changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that every runtime read of a frame descriptor is alignment-agnostic, stop
aligning the frame tables: the compiler no longer emits inter-descriptor or
internal padding (emitaux), the hand-written runtime frame table drops its
.align directives (amd64.S), and the runtime frame-table walk no longer rounds
up to 32-bit/word boundaries. Backtrace slots are shift-encoded (the descriptor
address is stored shifted, with the low bits used as a tag) so they no longer
rely on descriptors being aligned.

This packs the descriptors: on ocamlopt.opt the average descriptor shrinks from
21.8 to 18.8 bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The debuginfo filename and defname strings were emitted inline, once per
compilation unit. Split the defname out of the name_info / name_and_loc_info
structs into a [defname_offs] field (alongside the existing [filename_offs]),
and emit both the filename and defname strings into a mergeable string section
(SHF_MERGE|SHF_STRINGS, ".rodata.str1.1") so the linker deduplicates identical
strings across all units. The name structs stay in the frame table section,
referencing the strings by signed 32-bit offset.

Because the strings now live in a shared, deduplicated section, they can no
longer be attributed to a single frametable, so the measurement no longer
counts them: caml_debuginfo_measure brackets only the name structs and counts
records (each two 32-bit words); the frametable measurement reports
records*8 + struct-span as the debuginfo size.

On ocamlopt.opt this drops the measured debuginfo from 8.50 to 6.84 MiB and,
in the linked executable, deduplicates the physical strings across units.

On macOS the strings stay in the frametable section, un-deduplicated:
Mach-O relocations cannot target the assembler-local labels that define
them. The ARM64 binary emitter (JIT / verify-binary-emitter) keeps them
there too: in JIT mode the mergeable section contains no symbol to anchor
cross-section relocations, and in verify mode GAS keeps local-label
relocations into SHF_MERGE sections, so the two outputs would diverge.

On macOS the strings go to __TEXT,__cstring,cstring_literals under
l-prefixed private symbols (the new Asm_label.create_private_int):
Mach-O relocations cannot target L temporaries, but private symbols
survive into the object symbol table and anchor SUBTRACTOR/UNSIGNED
pairs, and ld64 coalesces the literals. String references go through
the new efa_string_label_rel action so the backends can render them in
the private form. The ARM64 binary emitter (JIT / verify-binary-emitter)
keeps the strings in the frametable section: in JIT mode a mergeable
section has no symbol to anchor cross-section relocations, and in
verify mode GAS keeps local-label relocations into SHF_MERGE sections,
so the two outputs would diverge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a denser per-descriptor encoding, chosen by the assembler per descriptor:

  * A leading ULEB128 byte sequence holds the return-address delta from the
    previous descriptor (requiring the increasing-address ordering established
    earlier). A leading 0 byte (FRAME_DELTA_ESCAPE) means "escape": the bytes
    that follow are a descriptor in the existing normal/long format.
    A frame_descr pointer addresses the size+flags byte (short) or the escape
    byte itself (escaped), so a descriptor is classified by its own first
    byte, the size+flags byte being never zero. The last byte of the
    preceding delta cannot be used for this: assemblers may pad a ULEB128
    to a non-minimal encoding ending in a zero byte, as LLVM's MC does
    when the delta spans an alignment boundary.
  * A short descriptor stores a 6-bit frame size (1..63 16-byte units) plus flags,
    separate num_allocs byte, 4-bit alloc-size nibbles, a 1-byte hot-register
    bitmap over the eight commonest GC-root registers (per-architecture, see
    Arch.frame_hot_regs / caml_frame_hot_regs), and a word-granular live
    stack-slot bitmap over the frame (width derived from the frame size).
    Descriptors that do not fit (large/odd frame size, cold register, live
    slot outside the frame, first-in-table, text-section boundary, ...)
    escape to the normal/long format.

The runtime gains caml_decode_frame_descr to decode either form, and the GC
stack scan, backtrace, signal handling and the frametable measurement all go
through it; the measurement also breaks escaped descriptors down by reason.
The hash table is keyed on {retaddr, fd}.

All backends emit the format: amd64 and ARM64, via GAS and via the binary
emitters (which resolve the variable-width delta directly; the x86 internal
assembler resolves text labels across sections via a per-program registry,
with text-like sections assembled first). Only MASM, which has no .uleb128,
escapes every descriptor (Emitaux.disable_short_descriptors). LLVM-backend
compilations set the oxcaml_short_frametables module flag so the frametable
printer there emits compatible (escaped) descriptors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the unconditional per-function section-epoch bump with
Emitaux.enter_code_section, which bumps only when the text-section name
changes. In the default shared-.text case each unit forms one delta
chain, so a function's first descriptor delta-encodes against the
previous function's last instead of escaping (~8-13 bytes -> 2-5 per
boundary). Function-sections, basic-block-sections and startup-section
builds still escape at every genuine section change.

Note: binaries built with -function-sections (including ocamlopt.opt
itself) gain almost nothing, by design; the oxcaml#6399 estimate for this
item assumed shared-.text links.
On Mach-O, function boundaries remain delta boundaries: the assembler
will not fold label differences across atoms (each non-private symbol
starts one), and .uleb128 has no relocated form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The short-frametable format needs LLVM's OxCamlGCPrinter to emit the
escape delta byte and drop descriptor padding
(ocaml-flambda/llvm-project#37); the oxcaml-llvmize-16.0.6-minus3
release predates that, so the llvmize tests segfault.

Temporarily point the llvmize job at a personal-fork release built
from oxcaml#37's head. Once oxcaml#37 is merged and an official release exists,
drop oxcaml_llvm_repo and bump oxcaml_llvm_tag instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NickBarnes
NickBarnes marked this pull request as draft July 14, 2026 15:56
@NickBarnes

Copy link
Copy Markdown
Contributor Author

Breaking this apart into at least three PRs (measurement, plumbing, and pure win).

Mach-O assemblers refuse a cross-atom label difference emitted inline,
but accept one bound with .set, which forces assembly-time evaluation;
the DWARF machinery already relies on this (Direct_assignment). On
macOS, print a Frame_descr_delta as

  .set Lfd_deltaN, (upper - lower)
  .uleb128 Lfd_deltaN

and stop breaking delta chains at function boundaries there. The change
is confined to the printer so that the directive stream is unchanged:
the arm64 offset-accounting path special-cases Frame_descr_delta, and
emitting a raw variable-based Uleb128 instead would hit its
non-integer-constant fatal. The L prefix keeps the temporaries
assembler-local.

Experiment for the darwin CI jobs to arbitrate. Known caveat if it
works: a delta chain drops the per-function relocations that used to
keep -dead_strip honest, which needs either .no_dead_strip or a
documented constraint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NickBarnes
NickBarnes force-pushed the frametable-rearrange branch from 9b03b5a to 244fb79 Compare July 15, 2026 13:58
pull Bot pushed a commit to DaviRain-Su/oxcaml that referenced this pull request Jul 25, 2026
Various changes to backend code emitters to enable oxcaml#6399 (shorter
frametables).
Basically this lets the backends emit ULEB128-encoded deltas between
code labels, and to notice when code sections change (to disable the
compact frametable format when a return-address delta isn't a
compile-time constant).

---------

Co-authored-by: Claude Fable 5 <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.

2 participants