Reduce frametable size#6399
Conversation
8fe34c9 to
4acf45a
Compare
|
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". |
7705cde to
a06c2e7
Compare
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>
213e680 to
e3692c4
Compare
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>
e3692c4 to
aae75d8
Compare
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>
aae75d8 to
c6379ea
Compare
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>
c6379ea to
696cb17
Compare
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>
|
Back out of draft and ready for review @mshinwell @stedolan. Saving 40% of all |
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>
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>
696cb17 to
1992279
Compare
|
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 |
|
Further work suggested by this:
|
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>
1992279 to
a0b91f4
Compare
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>
|
Breaking this apart into at least three PRs (measurement, plumbing, and pure win). |
a0b91f4 to
9b03b5a
Compare
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>
9b03b5a to
244fb79
Compare
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>
Rework the native-code frame tables to save space. Six main steps:
frame_descriptors.h);Results on
rodatasection sizes:ocamlopt.opt(14.57 MiB)Results on executable sizes:
ocamlopt.opt(77.08 MiB)Note: the "cross-function deltas" change has little effect if
-ffunction-sectionsis 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
Xmeasure_frametables=1) — instrumentation: report the size and composition of the frametables registered with the runtime.caml_all_frametablessection; 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).frame_descr *as an opaque byte pointer; read fields viacaml_read_unaligned_*at explicit offsets, so descriptors need no alignment.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 theoxcaml_short_frametablesmodule 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..textcase 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).