Skip to content

llext: make large C++ relocatable modules loadable and faster to load - #11316

Draft
lgirdwood wants to merge 2 commits into
thesofproject:mainfrom
lgirdwood:llext-relocatable-merge
Draft

lgirdwood wants to merge 2 commits into
thesofproject:mainfrom
lgirdwood:llext-relocatable-merge

Conversation

@lgirdwood

Copy link
Copy Markdown
Member

Large C++ LLEXT modules (e.g. anything built with TensorFlow Lite Micro) can't be loaded with CONFIG_LLEXT_TYPE_ELF_RELOCATABLE. When they can be loaded, llext_load() is slow. This PR fixes both problems in llext_link_helper.py. Nothing changes in the firmware or the loader.

Problem

The partial (-r) link keeps every C++ COMDAT group (inline functions, template instances, vtables) as its own section. A wake-word module built with TFLM ends up with 5721 sections and 1273 relocation sections, and then:

  • every executable section except .text is linked at address 0, i.e. as a DRAM section, so 1639 of its 1640 code sections, including the hot TFLM kernels, would run from IMR
  • sections pulled in from libraries at the final link aren't in the object the helper inspects, so they get no address and also stay at 0 (vtables, rodata)
  • the LLEXT section map alone needs 45KiB, so llext_load() fails with -ENOMEM on the default 8KiB LLEXT heap; with a bigger heap, llext_manager_load_module() rejects the module because .bss isn't adjacent to .data any more

Changes

  1. Merge sections of relocatable modules. Before calculating addresses, the helper first partially links all objects and libraries into one object, using --force-group-allocation and a small linker script (scripts/llext_merge_sections.ld) that folds .text.*, .rodata.*, .data.* and .bss.* into one section each. .cold, .coldrodata and other specially named sections are untouched. If the linker rejects the option, the helper warns and falls back to the old behaviour.
  2. Drop relocations that need no run-time action. About 85% of the relocations in a partially linked Xtensa object are no-ops for the loader: R_XTENSA_NONE, R_XTENSA_ASM_EXPAND, and R_XTENSA_SLOT0_OP for PC-relative references within a section, which the linker has already resolved. The loader still reads each entry, its symbol and the instruction from the module image in IMR, at about 1.7µs per cache miss on PTL. The helper now removes them after linking, using the same check as the loader's r_xtensa_slot0_op_relocate(), so the result is identical.

Results

TFLM wake-word module on PTL (aphid), GCC (Zephyr SDK 1.0.1), first instantiation:

before after
sections 5721 (1685 run from IMR) 25 (only .cold in IMR)
relocations 14764 2162
.llext size 1028KiB 657KiB merged, 527KiB also pruned¹
llext_load() fails (-ENOMEM); ~208ms with a 64KiB heap, then mapping fails ~20ms
inference — 698k cycles (768k when built into the base firmware)

¹ including ~10KiB of libc/libgcc code that the module links in on the branch below.

The relocation pass alone went from 73ms to 21ms. Of the remaining ~20ms total, about half was building ext->sym_tab, which a Zephyr change removes (see below). Mapping and copying into SRAM adds another 14–34ms, limited by IMR read bandwidth (~15MB/s).

For C modules nothing much changes: they mostly have a handful of sections already, and the pruning only removes entries the loader ignores. I checked that volume builds into the same layout either way.

Dependencies / not in this PR

Loading C++ modules end to end on Xtensa also needs these Zephyr LLEXT fixes, on lgirdwood/zephyr llext-cpp-modules (not sent upstream yet):

  • dispatch STB_WEAK symbols
  • resolve symbols defined in the extension directly
  • weak GOT addend fix
  • handle R_XTENSA_PLT
  • resolve undefined weak symbols to 0
  • build ext->sym_tab only when it's used
  • export snprintk/vsnprintk, sys_clock_cycle_get_32 and xtensa_arch_except

These are independent of this PR. The module-specific changes (extra exports, linking libc/libgcc into the module) are on lgirdwood/sof llext-cpp-modules.

Testing

  • PTL: built with GCC, loaded and run on silicon; inference runs with stable cycle counts over repeated runs and module reloads. Model outputs weren't compared against a built-in reference.
  • The helper from this branch was run against the objects of a PTL build: for the C++ module and for volume it produces the same sections and relocations as the in-tree build.
  • Not tested: xt-clang and LLVM linkers. Without --force-group-allocation support the helper falls back to the old behaviour.

🤖 Generated with Claude Code

lrgirdwo and others added 2 commits October 15, 2026 22:22
With CONFIG_LLEXT_TYPE_ELF_RELOCATABLE modules are partially linked
(-r). A partial link keeps every C++ COMDAT group (inline functions,
template instances, vtables) as a separate section, so C++ modules end
up with thousands of sections: a wake-word module built with TensorFlow
Lite Micro has 5721 sections and 1273 relocation sections.

That breaks loading in several ways:
- all executable sections except .text are linked at address 0 to run
  from DRAM, so 1639 of its 1640 code sections, including the hot TFLM
  convolution kernels, would execute from IMR
- the section addresses are calculated from the module object only, so
  sections pulled in from libraries at the final link aren't assigned
  an address at all and are left at 0, also as "DRAM" sections, among
  them C++ vtables and read-only data
- the LLEXT section map alone needs 45KiB, so llext_load() fails with
  -ENOMEM with the default 8KiB LLEXT heap, and with a bigger heap
  the module can't be mapped because .bss isn't next to .data any more

First partially link all objects and libraries of a relocatable module
into one object, with --force-group-allocation to dissolve the section
groups and a linker script to merge all .text.*, .rodata.*, .data.* and
.bss.* sections into one each, the same as a final link does. Then
calculate addresses for that object. Specially named sections like
.cold and .coldrodata aren't affected. If the linker doesn't support
this, fall back to the previous behaviour with a warning.

That module goes down to 25 sections, with only the .cold section in
DRAM, and its file size from 1028KiB to 657KiB. Together with further
fixes in the Zephyr LLEXT loader and in the module build, it then loads
and runs on PTL, without any inference slowdown compared to building it
into the base firmware. C modules mostly have a handful of sections
anyway.

Signed-off-by: Liam Girdwood <liam.r.girdwood@linux.intel.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A partially linked Xtensa object keeps relocation entries which the
LLEXT loader processes but doesn't act on: R_XTENSA_NONE and
R_XTENSA_ASM_EXPAND ones, and R_XTENSA_SLOT0_OP ones for PC-relative
L32R and CALLn references within a section, which the linker has
already resolved, so the loader leaves the instruction unchanged.

These are the vast majority: 12600 of 14764 relocations in a C++ module
built with TensorFlow Lite Micro. Still for each of them the loader
reads the relocation entry, the symbol and the instruction from the
module image in IMR, where each cache miss costs about 1.7us on PTL.
That took 73ms of the 86ms llext_load() time of that module.

Remove them from the relocation sections after linking. For
R_XTENSA_SLOT0_OP this uses the same check as the loader, so the result
is identical. With this, the relocation pass of that module takes 21ms.

Signed-off-by: Liam Girdwood <liam.r.girdwood@linux.intel.com>
Co-Authored-By: Claude Opus 5.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