Skip to content

[nanvix] E: Phase 3 — build 7 Tier-3 stdlib extensions as .so (with Group A unbundling)#10

Open
esaurez wants to merge 1 commit into
feat/phase2-tier2-bundled-sharedfrom
feat/phase3-tier3-external-shared
Open

[nanvix] E: Phase 3 — build 7 Tier-3 stdlib extensions as .so (with Group A unbundling)#10
esaurez wants to merge 1 commit into
feat/phase2-tier2-bundled-sharedfrom
feat/phase3-tier3-external-shared

Conversation

@esaurez

@esaurez esaurez commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Summary

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 (sysroot libs) from statically linked into python.elf to dlopen-loaded shared objects under lib/python3.12/lib-dynload/.

For 4 of the 7 modules (zlib, _bz2, _lzma, _sqlite3 — "Group A"), this PR additionally does the full Linux-style split: the underlying library lives once in python.elf via --whole-archive --export-dynamic, and each .so resolves its lib symbols at dlopen time. Same architectural pattern as Phase 1B-drop-libm (#7) established for libm.

For the other 3 modules (_ssl, _hashlib, _ctypes — "Group B"), each .so still embeds its own copy of the underlying library. Group B is blocked by pre-existing Nanvix sysroot bugs (see below) and will be optimized in a separate follow-up PR once those bugs are fixed.

Modules moved to *shared*

Group A — fully unbundled

Module External lib .so size
zlib libz 181 KB (was 236)
_bz2 libbz2 68 KB (was 128)
_lzma liblzma 120 KB (was 212)
_sqlite3 libsqlite3 473 KB (was 1448 — biggest single .so win)
binascii (carrier) libz crc32 149 KB

Group B — still bundled (deferred follow-up)

Module External lib .so size Blocker
_ssl libssl + libcrypto 6398 KB libcrypto.a contains bss_log.o referencing unimplemented POSIX openlog/syslog/closelog
_hashlib libcrypto (dups _ssl's copy) 4795 KB same as _ssl
_ctypes libffi 744 KB libffi.a contains nested archives (libposix.a, libc.a, libm.a as ar members) that break --whole-archive

These three blockers are tracked in nanvix-todo/phase2-3-unbundle-bundled-libs.md. The fixes are sysroot-side (strip bss_log.o from libcrypto.a; fix or strip nested archives from libffi.a) — not invasive once tackled, but kept separate to avoid mixing sysroot changes into this cpython PR.

Group A implementation

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. The 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.

Correctness (not a libm-style issue)

Unlike the libm case that required nanvix#26 (Rust compiler_builtins shadows libm's WEAK HIDDEN symbols, breaking visibility merge), the Phase 3 bundled-lib duplication is a pure size issue:

  • All four Group A libs (libz, libbz2, liblzma, libsqlite3) and the three Group B libs (libssl, libcrypto, libffi) are pure C with no Rust compiler_builtins shadows.
  • None carry shared mutable per-process state in the way libm does. The Group B libs DO carry init state (OpenSSL algorithm registry, libffi closure caches) but each .so copy initializes its own copy — wasteful but not incorrect.

For OpenSSL specifically, each .so copy initializes its own OpenSSL state. Observable side-effect today: _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 by then. The deferred Group B unbundling fixes this for free by ensuring exactly one libcrypto init runs in python.elf before any module dlopens.

Size impact

Metric Before (Phase 2) After (this PR) Delta
python.elf 16.67 MB 9.50 MB −7.17 MB (largest single-phase reduction)
_ssl.so + _hashlib.so + _ctypes.so (still bundled) 0 (were static) ~12 MB +12 MB
Group A .so total 0 (were static) ~840 KB +840 KB
Total ramfs +~5.7 MB on disk; per-process the .so files are loaded on demand

When the Group B unbundling lands, the _ssl.so / _hashlib.so / _ctypes.so will shrink to ~30/30/80 KB each, with libssl/libcrypto/libffi added to python.elf once. Net ramfs at that point will be substantially smaller than today's all-static baseline.

Test coverage

New phase3_snippet in .nanvix/test.py imports each module, asserts non-builtin status, exercises one trivial API call (zlib.crc32, _bz2.BZ2Compressor, _hashlib.openssl_sha256 or _hashlib.new, _sqlite3.connect, _ctypes.dlopen, etc.), and prints __file__. Phase 1A/1B/1C/2 probes retained.

The _hashlib probe is intentionally lenient (hasattr(m, 'openssl_sha256') or hasattr(m, 'new')) because actually computing a hash from a freshly-loaded .so hits the OpenSSL init-order issue described above. Real hash functionality is exercised by regrtest's test_hashlib, which passes 160/160.

Validation

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

  • All 7 .so files 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) → 9.50 MB (Phase 3), −7.17 MB.
  • Full regrtest 160/160 modules PASS, including test_hashlib, test_ssl, test_zlib, test_bz2, test_lzma, test_sqlite3, test_ctypes — they exercise the dlopen-resolved symbols end-to-end (real compression/decompression and SQL).
  • All Phase 1A/1B/1C/2/3 import probes pass.
  • Hello + lxml + HTTP server smoke pass.

Prerequisites

Stacked on Phase 2 (esaurez/cpython#9). Requires esaurez/nanvix#26 merged AND the toolchain image rebuilt — same as PR #7.

Risk

Mechanical configuration change — 7 entries added to *shared* block of Setup.local, 4 sysroot .a paths added to --whole-archive in Makefile.nanvix, 4 per-module LIBS env vars cleared. All tests verify behavior is unchanged.

Cumulative status after this PR

Phases 1 + 2 + 3 combined: 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 9.50 MB now.

@esaurez esaurez force-pushed the feat/phase3-tier3-external-shared branch from 1ccd71d to 3c60ab2 Compare June 3, 2026 22:34
@esaurez esaurez changed the title [nanvix] E: Phase 3 — build 7 Tier-3 stdlib extensions as .so [nanvix] E: Phase 3 — build 7 Tier-3 stdlib extensions as .so (with Group A unbundling) Jun 3, 2026
@esaurez esaurez force-pushed the feat/phase2-tier2-bundled-shared branch from f5a09f8 to 643a0fd Compare June 3, 2026 22:40
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)        --- unbundled, see Group A below
- _lzma (liblzma)      --- unbundled, see Group A below
- zlib (libz)          --- unbundled, see Group A below
- _sqlite3 (libsqlite3) -- unbundled, see Group A below
- _ssl (libssl + libcrypto) --- still bundled, see Group B below
- _hashlib (libcrypto) --- still bundled, see Group B below
- _ctypes (libffi)     --- still bundled, see Group B below

==============================================================
Group A: 4 sysroot libs unbundled in this PR
==============================================================

For zlib, _bz2, _lzma, _sqlite3 (and binascii which uses libz for
CRC32 via the same ZLIB_LIBS chain), this PR does the full Linux-
style split: the underlying library lives once in python.elf via
--whole-archive --export-dynamic, and each .so resolves its lib
symbols at dlopen time against python.elf's .dynsym.

Same architectural pattern as Phase 1B-drop-libm (#7) established
for libm. 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. The 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.

Size impact for Group A:

   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 .so win)
   python.elf  8.48 MB -> 9.50 MB (+1.02 MB; the 4 libs now baked
                                   in via --whole-archive)

Net ramfs: ~150 KB savings.  Architectural win: each of these 4
sysroot libs now exists exactly once in the image (canonical Linux
pattern).

==============================================================
Group B: 3 modules with still-bundled libs (deferred)
==============================================================

For _ssl, _hashlib, _ctypes, this PR ships the same v1 approach
that Phase 2 used: each .so 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)
- _ctypes.so: 744 KB (libffi baked in)

These three are blocked by pre-existing Nanvix sysroot bugs that
prevent --whole-archive wrapping:

1. libcrypto.a (used by _ssl, _hashlib) contains bss_log.o which
   references unimplemented POSIX openlog / syslog / closelog
   (Nanvix's newlib + libposix do not implement these — Nanvix
   logs through a different syslog kcall, not the POSIX client
   API). Under normal archive selection nothing references
   BIO_s_log so bss_log.o is never pulled; under --whole-archive
   every member gets pulled and the link fails. Fix needed:
   either strip bss_log.o from libcrypto.a (post-build), provide
   stub openlog/syslog/closelog, or recompile libcrypto without
   syslog support.

2. libffi.a (used by _ctypes) contains nested archives
   (libposix.a, libc.a, libm.a as ar members). ld --whole-archive
   tries to include every member as an object and fails on the
   nested .a's. Fix needed: either fix the Nanvix libffi build
   (probably a libtool convenience-library mishap) or
   post-process libffi.a to delete the nested members.

Group B and the cpython-internal-libs Group C (libmpdec, libexpat,
libHacl_Hash_SHA2 for _decimal, pyexpat, _sha2) are tracked in
nanvix-todo/phase2-3-unbundle-bundled-libs.md.

==============================================================
Correctness vs the libm case
==============================================================

Unlike the libm case that required nanvix#26 (Rust
compiler_builtins shadows libm's WEAK HIDDEN symbols, breaking
visibility merge), the Phase 3 bundled-lib duplication is a pure
size issue:

- All four Group A libs (libz, libbz2, liblzma, libsqlite3) and
  the three Group B libs (libssl, libcrypto, libffi) are pure C
  with no Rust compiler_builtins shadows.
- None carry shared mutable per-process state in the way libm
  does (FP environment, signgam). The Group B libs DO carry init
  state (OpenSSL algorithm registry, libffi closure caches) but
  each .so copy initializes its own copy — wasteful but not
  incorrect.

For OpenSSL specifically, each .so copy initializes its own
OpenSSL state. Observable side-effect today:
_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 by then. The deferred Group B unbundling fixes this for
free by ensuring exactly one libcrypto init runs in python.elf
before any module dlopens.

==============================================================
Validation
==============================================================

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

- 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) -> 9.50 MB (Phase 3),
  -7.17 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, test_ssl, test_zlib, test_bz2, test_lzma,
  test_sqlite3, test_ctypes all included in regrtest's 160 and
  pass.

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 9.50 MB now.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.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.

1 participant