Skip to content

Register package members on first use - #226

Draft
kimstik wants to merge 1 commit into
CadQuery:masterfrom
kimstik:fast_import
Draft

kimstik wants to merge 1 commit into
CadQuery:masterfrom
kimstik:fast_import

Conversation

@kimstik

@kimstik kimstik commented Sep 16, 2026

Copy link
Copy Markdown

Depends on CadQuery/pywrap#65

import OCP registers 321 packages, 5.8k types and 76k .def() calls, about 0.8 s here (Windows, py3.12, cadquery-ocp 8.0.1.0.0).
With lazy = true in ocp.toml the types are still created at import, and the members of a package are registered when the package is first used:
attribute access, dir(), star import, or a C++ object of that package crossing into Python.
OCP_LAZY=0 restores the old behaviour; OCP._load_all() registers everything at once.

Measured on a wheel built from this branch (same OCCT/VTK/pybind11 as 8.0.1.0.0, 5 alternating runs, best):

import OCP import cadquery
8.0.1.0.0 wheel 0.79 s 1.15 s
this branch, OCP_LAZY=0 0.79 s 1.16 s
this branch 0.16 s 0.94 s

Checked: CI on Linux/Windows/OSX x py3.10-3.13 (import OCP; OCP._load_all(), OCP_LAZY=0, tests, stubs).
The cadquery test suite on the wheel: 788 passed, 3 skipped; dir(), sys.modules, pickling, help(), threads, spawn multiprocessing and the generated stubs are identical to the eager build.
The only new public name is OCP._load_all().

What changes for a caller: a class reached without its package - through __subclasses__(), __bases__, or a default argument value - has no members until that package is used.
An attribute set on a package module before its first use is overwritten when the package registers.


Registering on first use is common practice for large bindings:

  • pythonocc-core - one extension module per OCCT package, 308 of them
  • sip (PyQt, wxPython) - type attributes created on first access
  • PySide6 - types created on demand since 6.7
  • VTK - vtkmodules, over one hundred modules, import only what is needed
  • SciPy, NumPy - submodules loaded on first access
  • PEP 562, PEP 810

For scale: OCP registers 321 packages, 5.8k types and 76k .def() calls in PyInit.
A plain import OCP costs 0.79 s here, 0.16 s with per-package registration on first use.
CadQuery touches 87 packages.

@adam-urbanczyk

Copy link
Copy Markdown
Member

Thanks, may I ask what is your use case? Why would I want to merge this vs. what are the downsides like maintenance overhead etc? Could you also provide some references/docs/voiceover to the additions in OCP inc?

@kimstik

kimstik commented Sep 16, 2026

Copy link
Copy Markdown
Author

Thanks, may I ask what is your use case? Why would I want to merge this vs. what are the downsides like maintenance overhead etc? Could you also provide some references/docs/voiceover to the additions in OCP inc?

My use case is plain: I run cadquery from short scripts, one of them takes a model and writes STEP, and each run is a fresh process that sits for over two seconds before doing anything. Most of that wait is import OCP registering every package while the script needs a handful of them; the table above shows that wait cut. What is left is cadquery's own imports, which I am trimming separately. Anyone who starts processes often (tests, CI, CQ-editor, notebooks) has the same wait.

The downsides: about two hundred lines of C++ in OCP_specific.inc to own. They touch pybind11 at two documented points (polymorphic_type_hook, detail::get_type_info) and CPython at one (module __getattr__/__dir__, PEP 562).
Nothing in them depends on OCCT, so OCCT upgrades do not touch them; a pybind11 major upgrade would need a look.
Two visible edges: a class reached without its package (__subclasses__(), __bases__, a default argument value) has no members until the package is used, and an attribute set on a package module before its first use is overwritten when it registers. OCP_LAZY=0 restores the old behavior. CI runs both modes.
The pywrap side (CadQuery/pywrap#65) is roughly a hundred lines behind a flag that is off by default.

The inc, in short:

  • Phase 1 (register_<mod>_enums: submodules, enums, class declarations, exceptions) still runs for every package at import, so every type exists.
  • Phase 2 (register_<mod>: the members) is deferred:
    • install() takes the generated table and calls hide() on each package, which parks the package's names and installs __getattr__/__dir__;
    • ensure() runs on first use - base-class owners first, then the names come back and register_<mod> runs;
    • the polymorphic_type_hook specialisation makes every C++ -> Python cast load the owner of the object's type first.

sip (PyQt) has done this for type attributes since 2002; PySide6 6.7 does it for types. If a comment block in the inc or a page under docs/ would help, tell me which and I will add it.

@kimstik

kimstik commented Sep 16, 2026

Copy link
Copy Markdown
Author

I tried to keep this as nonintrusive as possible: nothing changes unless the lazy flag is set.

@adam-urbanczyk

Copy link
Copy Markdown
Member

OK, I think I'll be eventually fine with merging this. Not sure yet if it should be the default. Few requests and questions:

  1. Could you refactor this into a separate header?
  2. Description in the header is a must, also I'd like to have every function have at least a brief docstring.
  3. I might soon need to do something unrelated with polymorphic_type_hook. I would need to integrate your code then? I.e. there is no way to decouple changes in the hook, right?

@kimstik

kimstik commented Sep 20, 2026

Copy link
Copy Markdown
Author
  1. Could you refactor this into a separate header?
  2. Description in the header is a must, also I would like to have every function have at least a brief docstring.
  3. I might soon need to do something unrelated with polymorphic_type_hook. I would need to integrate your code then? I.e. there is no way to decouple changes in the hook, right?

No problem with any of it. These are natural asks for a shared understanding of the code.
I tried to stay as close to the existing style as I could, and had wrongly assumed that comments were kept to a minimum on purpose.

  1. Easy enough.

  2. Brief docstrings: OK.

  3. Yes, it can go.
    The hook covers one case: an object of a package that has not registered yet comes back from C++. A __getattr__ on each class of such a package does the same on the first attribute access on an instance.
    It is set through setattr on the type and removed once the package registers, so nothing is left in the steady state. That removes the polymorphic_type_hook specialisation and the use of detail::get_type_info.
    What it does not cover is a static member reached through the class of such an object before any instance attribute was touched. That is the price of this variant.
    Choosing between the hook and the __getattr__, I prefer the __getattr__. It leaves pybind11's hook to you and drops the one detail:: use in the code.
    I will try the __getattr__ way and update the PR if nothing regresses.

As for the default, OCP_LAZY can go either way, opt-in or opt-out. It is one line in install().

btw, I am about to open a PR that takes the CI build from 4 h to about 1h30, and a cache on top that gets consecutive builds down to half an hour.

@adam-urbanczyk

Copy link
Copy Markdown
Member

Regarding build times, I am interested (though not so much in caching, I want the build to be stateless). But could you first open an issue and discuss your approach? This way we can avoid possible wasted effort.

@kimstik

kimstik commented Sep 20, 2026

Copy link
Copy Markdown
Author

I started to doubt __getattr__.
Operators, __hash__, __iter__ and pickling never reach it. CPython takes those from the type slots.
So I went with tp_alloc instead. It is the more complete trigger.

pybind11 creates every instance through type->tp_alloc.
hide() replaces that slot on the classes of a hidden package and ensure() puts it back.
Python-level subclasses always get PyType_GenericAlloc from the class statement and are not touched.
It also covers a call of the class, which the cast hook did not.

This removes the polymorphic_type_hook specialisation and the detail::get_type_info call.

Patching tp_alloc on every type after the fact is the price.
Not sure I picked right. What do you think?

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