Skip to content

[nanvix] E: Unbundle libsqlite3 / libz / libbz2 / liblzma from Phase 3 .so#12

Closed
esaurez wants to merge 3 commits into
feat/phase4-tier4-lxml-sharedfrom
feat/phase3-unbundle-groupA-zlib-bz2-lzma-sqlite3
Closed

[nanvix] E: Unbundle libsqlite3 / libz / libbz2 / liblzma from Phase 3 .so#12
esaurez wants to merge 3 commits into
feat/phase4-tier4-lxml-sharedfrom
feat/phase3-unbundle-groupA-zlib-bz2-lzma-sqlite3

Conversation

@esaurez

@esaurez esaurez commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Summary

Group A of the deferred Phase 2/3 unbundling work (see nanvix-todo/phase2-3-unbundle-bundled-libs.md). Promotes 4 of the Phase 3 sysroot-ported libraries from "duplicated into each .so" to "single copy in python.elf, .so resolves at dlopen time".

This is the same architectural pattern that Phase 1B-drop-libm (#7) established for libm. Group A extends it to the four "clean" sysroot libs that have no pre-existing sysroot bugs.

Affected modules

zlib, _bz2, _lzma, _sqlite3 (and binascii, which uses libz for CRC32 via the same ZLIB_LIBS chain).

Changes in Makefile.nanvix CONFIGURE_ENV

  1. python.elf's LIBS is extended with a --whole-archive ... --no-whole-archive group containing libsqlite3.a, libz.a, libbz2.a, liblzma.a. The four libs now live exactly once in python.elf, with every public symbol exported via --export-dynamic.

  2. Per-module LIBS env vars used by cpython's configure to inject -l<lib> into individual .so links are cleared (ZLIB_LIBS="", BZIP2_LIBS="", LIBLZMA_LIBS="", LIBSQLITE3_LIBS=""). The .so files now reference compress2 / BZ2_bzCompress / lzma_code / sqlite3_exec etc. as UND symbols; the dynamic loader resolves them at dlopen time against python.elf's .dynsym.

NOT included in this PR (deferred to follow-ups)

  • libcrypto / libssl (used by _ssl, _hashlib): blocked by libcrypto.a containing bss_log.o which references unimplemented POSIX openlog/syslog/closelog.
  • libffi (used by _ctypes): blocked by libffi.a containing nested archives (libposix.a, libc.a, libm.a as ar members).
  • libmpdec / libexpat / libHacl_Hash_SHA2 (used by _decimal, pyexpat, _sha2): blocked by chicken-and-egg at configure time (cpython builds these .a files later).
  • libxml2 / libxslt / libexslt / liblxml_etree / liblxml_elementpath (used by _lxml_etree, _lxml_elementpath): kept as a separate Group D PR to keep diffs small.

Size impact

Module / metric Before After Delta
zlib.so 236 KB 181 KB −55 KB
_bz2.so 128 KB 68 KB −60 KB
_lzma.so 212 KB 120 KB −92 KB
_sqlite3.so 1448 KB 473 KB −975 KB (biggest single win)
python.elf 8.48 MB 9.50 MB +1.02 MB (libsqlite3 + libz + libbz2 + liblzma baked in once)
Net ramfs ~150 KB savings

The bigger win is architectural: each library now exists exactly once in the image, matching the canonical Linux pattern (libm.so.6, libsqlite3.so.0, etc. are single instances).

Validation

Tested on phase0-llfix toolchain overlay (containing the patched libposix.a from esaurez/nanvix#26):

  • Full regrtest 160/160 modules pass, including test_zlib, test_bz2, test_lzma, test_sqlite3 (real compression/decompression and SQL operations exercise the dlopen-resolved symbols end-to-end).
  • All Phase 1A/1B/1C/2/3/4 import probes continue to pass.
  • Hello + lxml + HTTP server smoke continue to pass.

Prerequisites

Stacked on Phase 4 (esaurez/cpython#11). Requires esaurez/nanvix#26 merged AND the toolchain image rebuilt — same as Phase 1B-drop-libm.

Risk

Low. The change is mechanical: 4 sysroot .a paths added to --whole-archive, 4 per-module LIBS env vars cleared. All tests verify behavior is unchanged. Reversible by undoing the Makefile.nanvix diff.

Enrique Saurez and others added 3 commits June 3, 2026 14:14
Phase 3 of the .a -> .so migration (see
nanvix-todo/cpython-static-to-shared-migration.md section 7).
Promotes 7 modules with external Nanvix-ported .a dependencies
from statically linked into python.elf to dlopen-loaded shared
objects.

Modules moved to *shared* in Modules/Setup.local generation
(.nanvix/docker.py):

- _bz2 (libbz2)
- _lzma (liblzma)
- zlib (libz)
- _ssl (libssl + libcrypto)
- _hashlib (libcrypto)
- _sqlite3 (libsqlite3)
- _ctypes (libffi)

Same trade-off as Phase 2: each .so currently embeds its own copy
of the underlying library, via the existing cpython per-module
LDFLAGS mechanism. The .so files are larger than ideal but
functionally complete:

- _ssl.so: 6398 KB (libssl + libcrypto baked in)
- _hashlib.so: 4795 KB (libcrypto baked in, duplicates _ssl's copy)
- _sqlite3.so: 1448 KB (libsqlite3 baked in)
- _ctypes.so: 744 KB (libffi baked in)
- zlib.so: 236 KB, _lzma.so: 212 KB, _bz2.so: 128 KB

Optimizing all 11 Phase 2 + Phase 3 modules to resolve bundled-lib
symbols at dlopen time against python.elf's .dynsym is captured in
nanvix-todo/phase2-3-unbundle-bundled-libs.md as a deferred
follow-up PR. The optimization requires either a two-pass configure
or linker-script-based approach, AND requires fixing two pre-existing
sysroot bugs:

1. libffi.a contains nested archives (libposix.a, libc.a, libm.a)
   that block --whole-archive. Either fix Nanvix's libffi build or
   post-process to remove the nested members.
2. libcrypto.a contains bss_log.o that references POSIX openlog /
   syslog / closelog (not implemented in Nanvix's newlib + libposix).
   Either strip bss_log.o from libcrypto.a or provide stub
   implementations.

Importantly, the bundled-lib duplication is a pure size issue, not a
correctness issue (unlike the libm case that required nanvix#26):
mpdec / expat / HACL / zlib / lzma / bz2 / sqlite / ffi are all
pure C with no Rust compiler_builtins shadows, and none carry shared
mutable per-process state. The visibility-merge correctness fix
required for libm does NOT apply.

For OpenSSL, each .so copy initializes its own copy of OpenSSL state
(algorithm registry, BIO method list, RAND state). Observed
side-effect: _hashlib.openssl_sha256() called BEFORE any other
OpenSSL init returns "unsupported hash type sha256"; calls from
regrtest's test_hashlib succeed because OpenSSL has been initialized
via _ssl elsewhere by then. The deferred unbundling fixes this for
free by ensuring exactly one libcrypto init runs in python.elf
before any module dlopens.

Validation on local toolchain (phase0-llfix):

- All 7 .so files produced and installed under lib-dynload/.
- nm python.elf no longer shows PyInit_<name> for any of the 7.
- python.elf size: 16.67 MB (Phase 2) -> 11.20 MB (Phase 3),
  -5.47 MB. Largest single-phase reduction so far.
- Hello + Phase 1A + 1B + 1C + 2 + 3 import probes + lxml + HTTP
  smoke + full regrtest 160/160 PASS in standalone mode.
- test_hashlib (regrtest) computes real sha256 successfully via
  dlopen'd _hashlib, confirming OpenSSL works end-to-end (the
  init-order quirk only affects callers that hit OpenSSL before
  any other initialization).

Phase 1 + Phase 2 + Phase 3 cumulative: 47 of 47 Tier-1/2/3
modules moved from static to .so. python.elf has dropped from
~20 MB at Phase 0 baseline to 11.2 MB now.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase 4 of the .a -> .so migration (see
nanvix-todo/cpython-static-to-shared-migration.md section 8).
Promotes the 2 lxml C extension modules (_lxml_etree,
_lxml_elementpath) from statically linked into python.elf to
dlopen-loaded shared objects.

Modules moved from *static* to *shared* in Modules/Setup.local
generation (.nanvix/docker.py):

- _lxml_etree (links liblxml_etree.a + libxslt.a + libexslt.a +
  libxml2.a + libz.a from the sysroot)
- _lxml_elementpath (links liblxml_elementpath.a + libxml2.a +
  libz.a)

Both modules use the same thin-shim approach the static variant
used: lxml_etree_builtin.c / lxml_elementpath_builtin.c each
register the C extension's PyInit_ entrypoint after pulling in
the Cython-generated implementation from
liblxml_etree.a / liblxml_elementpath.a in the sysroot.

The shim files are unchanged. The lxml/etree.py Python-level
shim that re-exports symbols from `_lxml_etree` continues to
work as-is, because Python's import system finds
.cpython-312.so files in lib-dynload/ exactly like static
modules — the import name `_lxml_etree` resolves either way.

Same trade-off as Phase 2 and Phase 3: each .so embeds its own
copy of the underlying libraries (libxslt, libexslt, libxml2,
libz). _lxml_etree.so is 3645 KB primarily because libxslt and
libxml2 are sizable. _lxml_elementpath.so is 175 KB because it
only needs libxml2.

This trade-off is tracked alongside the Phase 2 + Phase 3 bundled
libs in nanvix-todo/phase2-3-unbundle-bundled-libs.md as a
deferred follow-up optimization PR. Note that libz is already
shared with Phase 3's zlib.so today (each carries its own copy);
the same applies to libxml2 (only used by lxml; if libxml2 had
to be in python.elf for other consumers we'd see the same
visibility merge issue as libm, but it has no Rust
compiler_builtins shadows so we're safe).

Validation on local toolchain (phase0-llfix):

- Both .so files produced and installed under lib-dynload/.
- nm python.elf no longer shows PyInit__lxml_etree or
  PyInit__lxml_elementpath.
- python.elf size: 11.20 MB (Phase 3) -> 8.48 MB (Phase 4),
  -2.72 MB. python.elf now meets the migration plan's "<=10 MB
  stripped" success criterion from Section 10.
- Hello + all Phase 1/2/3 import probes + lxml import-and-parse
  smoke + HTTP server smoke + full regrtest 160/160 PASS in
  standalone mode.
- test_nanvix_lxml (regrtest's lxml-specific test) included in
  the 160/160 and passes.

This completes the migration plan's Phase 4 scope — only Phase 5
(optional dormant module revival for numpy / regex) remains.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…3 .so

Group A of the deferred Phase 2/3 unbundling work (see
nanvix-todo/phase2-3-unbundle-bundled-libs.md). Promotes 4 of the
Phase 3 sysroot-ported libraries from "duplicated into each .so" to
"single copy in python.elf, .so resolves at dlopen time".

Affected modules: zlib, _bz2, _lzma, _sqlite3 (and binascii, which
uses libz for crc32 via the same ZLIB_LIBS chain).

Changes in Makefile.nanvix CONFIGURE_ENV:

1. python.elf's LIBS is extended with a --whole-archive ...
   --no-whole-archive group containing:
       $(SYSROOT_PATH)/lib/libsqlite3.a
       $(SYSROOT_PATH)/lib/libz.a
       $(SYSROOT_PATH)/lib/libbz2.a
       $(SYSROOT_PATH)/lib/liblzma.a

   The four libs now live exactly once in python.elf, with every
   public symbol exported via --export-dynamic.

2. The per-module LIBS env vars used by cpython's configure to
   inject -l<lib> into individual .so links are cleared:
       ZLIB_LIBS=""        (also feeds BINASCII_LIBS via configure)
       BZIP2_LIBS=""
       LIBLZMA_LIBS=""
       LIBSQLITE3_LIBS=""

   This drops the underlying .a from each .so's link command. The
   .so files now reference compress2 / BZ2_bzCompress / lzma_code /
   sqlite3_exec etc. as UND symbols; the dynamic loader resolves
   them at dlopen time against python.elf's .dynsym.

This is the same architectural pattern that Phase 1B-drop-libm (#7)
established for libm. It is now extended to the four "clean" sysroot
libs that have no pre-existing sysroot bugs.

NOT included in this PR:

- libcrypto/libssl (used by _ssl, _hashlib): blocked by
  libcrypto.a containing bss_log.o which references unimplemented
  POSIX openlog/syslog/closelog. To be addressed in a follow-up PR
  that either strips bss_log.o from libcrypto.a or stubs syslog.
- libffi (used by _ctypes): blocked by libffi.a containing nested
  archives (libposix.a, libc.a, libm.a as ar members) that ld
  refuses to process under --whole-archive. To be addressed in a
  follow-up PR that either fixes the libffi build or strips the
  nested members.
- libmpdec / libexpat / libHacl_Hash_SHA2 (used by _decimal,
  pyexpat, _sha2): blocked by the chicken-and-egg that these .a
  files are built by cpython's own Makefile AFTER configure runs,
  so they cannot be referenced in LIBS at configure conftest time.
  To be addressed in a follow-up PR that uses either a two-pass
  configure or a linker-script INPUT() approach.
- libxml2 / libxslt / libexslt / liblxml_etree / liblxml_elementpath
  (used by _lxml_etree, _lxml_elementpath): same shape as Group A
  but kept as a separate Group D PR to keep diffs small.

Validation on local toolchain (phase0-llfix overlay containing the
patched libposix.a from esaurez/nanvix#26):

- .so size reductions:
    zlib.so       236 KB -> 181 KB   (-55 KB)
    _bz2.so       128 KB ->  68 KB   (-60 KB)
    _lzma.so      212 KB -> 120 KB   (-92 KB)
    _sqlite3.so  1448 KB -> 473 KB   (-975 KB, biggest single win)
- python.elf size:  8.48 MB -> 9.50 MB (+1.02 MB)
  (libsqlite3 + libz + libbz2 + liblzma now baked in once via
  --whole-archive instead of duplicated into each .so).
- Net ramfs delta: ~150 KB savings, single-copy architecture
  established for these four libs.
- Functional: full regrtest 160/160 modules pass, including
  test_zlib, test_bz2, test_lzma, test_sqlite3 (real
  compression/decompression and SQL operations exercise the
  dlopen-resolved symbols end-to-end).
- All Phase 1A/1B/1C/2/3/4 import probes continue to pass.
- Hello + lxml + HTTP server smoke continue to pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@esaurez esaurez force-pushed the feat/phase4-tier4-lxml-shared branch from 7c7e39f to 453b1e3 Compare June 3, 2026 22:34
@esaurez

esaurez commented Jun 3, 2026

Copy link
Copy Markdown
Owner Author

Folded into PR #10 (Phase 3) per esaurez review preference — easier to review the .so move and the unbundling together. The squashed Phase 3 commit on eat/phase3-tier3-external-shared now contains both changes (4 modules unbundled with --whole-archive, 3 modules left bundled awaiting Group B sysroot fixes). See updated PR #10 description.

@esaurez esaurez closed this Jun 3, 2026
@esaurez esaurez deleted the feat/phase3-unbundle-groupA-zlib-bz2-lzma-sqlite3 branch June 3, 2026 22:34
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