Skip to content

feat(embed): support baking PythonCall into a juliacall system image#1

Merged
ncudlenco merged 1 commit into
mainfrom
feat/embed-sysimage
May 19, 2026
Merged

feat(embed): support baking PythonCall into a juliacall system image#1
ncudlenco merged 1 commit into
mainfrom
feat/embed-sysimage

Conversation

@ncudlenco
Copy link
Copy Markdown
Member

@ncudlenco ncudlenco commented May 18, 2026

Summary

Lets PythonCall itself be compiled into a juliacall system image (opt-in). With it baked in, the using PythonCall that import juliacall performs becomes a memory-map instead of a multi-second load+compile. In a containerised juliacall workload we measured, fresh-process startup drops from ~18 s to ~1.9 s. Off by default — no behaviour change unless enabled. It's meant as a low-risk interim until the automatic fix discussed in JuliaPy#436 (or related work) lands; it doesn't conflict with or preclude that.

Motivation

In short-lived Python processes that embed Julia via juliacall — serverless or autoscaled containers (AWS Lambda, queue workers, CI jobs) that start, handle one request, and exit — there's no long-lived process to amortise Julia start-up. Every cold start pays it again, and the dominant part is the using PythonCall that import juliacall runs to bring the bridge up.

Measured, fresh container to first call:

container init
stock juliacall (only app packages in the sysimage) ~18 s
PythonCall also baked into the sysimage (this PR) ~1.9 s

(~0.68 s of the ~1.9 s is the Julia stack; the rest is container/runtime overhead common to any container.) The ~16 s saved is essentially the repeated using PythonCall load+compile.

PYTHON_JULIACALL_SYSIMAGE already exists, but a sysimage with only the application packages still runs using PythonCall every cold start. Baking PythonCall in too is the obvious fix, but it currently fails: when PythonCall is in the sysimage its __init__ runs during jl_init_with_image, before juliacall's bootstrap has set Main.__PythonCall_libptr. init_context() decides "embedded" only from that global, so it sees "not embedded"; since Python is mid-import juliacall, init_juliacall() then errors with 'juliacall' module already exists.

Change

A second, opt-in way to detect the embedded case, using the same getpref mechanism added in 0.9.33 (preference embedded / env JULIA_PYTHONCALL_EMBEDDED, default no):

  • src/Utils/Utils.jl — add getpref_embedded(), alongside the existing getpref_exe / getpref_lib / getpref_pickle.
  • src/C/context.jl — if the Main global isn't set but embedded is, take the embedded path and get libpython from the existing lib preference / JULIA_PYTHONCALL_LIB (it's already loaded in the host process, so dlopen just hands back a handle).
  • docs/src/juliacall.md — a short "Baking PythonCall into a system image" section.
  • CHANGELOG.md — Unreleased entry.

In practice it's used together with PYTHON_JULIACALL_SYSIMAGE (PythonCall baked in) and PYTHON_JULIACALL_EXE / PROJECT.

Backward compatibility

Off by default. The new path is only reached when embedded is explicitly set and Main.__PythonCall_libptr is absent. The normal juliacall path and the PythonCall-as-host path are unchanged.

Testing

The new code path only runs when explicitly enabled, so default behaviour is unchanged and the existing test suites are unaffected.

It was verified manually: with PythonCall baked into a system image and embedded enabled, import juliacall and calling Julia both work. No automated test is included because exercising it requires building a system image in CI, which is slow — happy to add one, or document the steps, if you'd prefer.

Related issues

Relates to JuliaPy#436 and JuliaPy#600 — this unblocks their use case (baking PythonCall into a custom system image) as an interim. The root-cause fix the maintainer described in JuliaPy#436 — resetting PythonCall's sysimage-persisted state in __init__ so it works with no opt-in — is the longer-term path; this PR doesn't attempt or preclude it.

Also relevant to JuliaPy#762 ("Improve juliacall startup time?", which explicitly asks about compiling a system image) and a building block for JuliaPy#76 ("Compile and use custom sysimages automatically").

Does not address JuliaPy#129 (a different failure: no environment in the LOAD_PATH depends on CondaPkg).

@ncudlenco ncudlenco closed this May 18, 2026
@ncudlenco ncudlenco reopened this May 18, 2026
@ncudlenco ncudlenco closed this May 18, 2026
@ncudlenco ncudlenco reopened this May 18, 2026
@ncudlenco ncudlenco force-pushed the feat/embed-sysimage branch from 80e15d0 to 7ed6b9e Compare May 18, 2026 21:48
When PythonCall is compiled into a juliacall system image, its __init__
runs during jl_init_with_image, before juliacall's bootstrap defines
Main.__PythonCall_libptr. Embedding was therefore mis-detected as
non-embedded and failed with "'juliacall' module already exists".

Add an opt-in embedded preference / JULIA_PYTHONCALL_EMBEDDED (via the
same getpref mechanism as exe/lib) that forces the embedded path and
obtains libpython from the lib preference / JULIA_PYTHONCALL_LIB (already
loaded in the host process). Unset, behaviour is unchanged. Docs and
CHANGELOG updated.
@ncudlenco ncudlenco force-pushed the feat/embed-sysimage branch from 7ed6b9e to 28dd020 Compare May 18, 2026 21:53
@ncudlenco ncudlenco merged commit b548223 into main May 19, 2026
17 of 18 checks passed
@ncudlenco
Copy link
Copy Markdown
Member Author

Merged into main. Do not delete the feat/embed-sysimage branch — it is the head of upstream JuliaPy#773 and must remain until that PR is resolved.

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