Skip to content

Commit f49b211

Browse files
authored
Merge pull request #285 from bbopen/perf/0.9-lazy-codec-imports
perf(runtime): type-first codec dispatch — stop cold-importing the scientific stack for plain values
2 parents 76a6583 + c04e4d0 commit f49b211

3 files changed

Lines changed: 94 additions & 39 deletions

File tree

runtime/tywrap_bridge_core.py

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import inspect
5050
import json
5151
import math
52+
import sys
5253
import traceback
5354
import uuid
5455
from pathlib import Path, PurePath
@@ -314,33 +315,33 @@ def module_available(module_name):
314315

315316

316317
def is_numpy_array(obj):
317-
try:
318-
import numpy as np # noqa: F401
319-
except Exception:
318+
"""Return whether obj is an ndarray without importing NumPy."""
319+
np = sys.modules.get('numpy')
320+
if np is None:
320321
return False
321322
return isinstance(obj, np.ndarray)
322323

323324

324325
def is_pandas_dataframe(obj):
325-
try:
326-
import pandas as pd # noqa: F401
327-
except Exception:
326+
"""Return whether obj is a DataFrame without importing pandas."""
327+
pd = sys.modules.get('pandas')
328+
if pd is None:
328329
return False
329330
return isinstance(obj, pd.DataFrame)
330331

331332

332333
def is_pandas_series(obj):
333-
try:
334-
import pandas as pd # noqa: F401
335-
except Exception:
334+
"""Return whether obj is a Series without importing pandas."""
335+
pd = sys.modules.get('pandas')
336+
if pd is None:
336337
return False
337338
return isinstance(obj, pd.Series)
338339

339340

340341
def is_scipy_sparse(obj):
341-
try:
342-
import scipy.sparse as sp # noqa: F401
343-
except Exception:
342+
"""Return whether obj is sparse without importing SciPy."""
343+
sp = sys.modules.get('scipy.sparse')
344+
if sp is None:
344345
return False
345346
try:
346347
return sp.issparse(obj)
@@ -349,9 +350,9 @@ def is_scipy_sparse(obj):
349350

350351

351352
def is_torch_tensor(obj):
352-
try:
353-
import torch # noqa: F401
354-
except Exception:
353+
"""Return whether obj is a Tensor without importing PyTorch."""
354+
torch = sys.modules.get('torch')
355+
if torch is None:
355356
return False
356357
try:
357358
return torch.is_tensor(obj)
@@ -360,11 +361,11 @@ def is_torch_tensor(obj):
360361

361362

362363
def is_sklearn_estimator(obj):
363-
try:
364-
from sklearn.base import BaseEstimator # noqa: F401
365-
except Exception:
364+
"""Return whether obj is an estimator without importing scikit-learn."""
365+
sklearn_base = sys.modules.get('sklearn.base')
366+
if sklearn_base is None:
366367
return False
367-
return isinstance(obj, BaseEstimator)
368+
return isinstance(obj, sklearn_base.BaseEstimator)
368369

369370

370371
# =============================================================================
@@ -748,26 +749,42 @@ def serialize_stdlib(obj):
748749

749750
def serialize(obj, *, force_json_markers, torch_allow_copy=False):
750751
"""
751-
Top-level result serializer. Dispatch order is significant: numpy ndarray ->
752-
dataframe -> series -> scipy.sparse -> torch -> sklearn -> Pydantic -> stdlib
753-
-> passthrough. The remaining BridgeCodec value behaviors (numpy/pandas scalars,
754-
bytes, sets, complex rejection, NaN/Infinity) are applied later during JSON
755-
encoding by default_encoder.
752+
Top-level result serializer.
753+
754+
Scientific codecs are type-first and only inspect packages that the value can
755+
belong to. A value from an optional package implies that package is already in
756+
sys.modules, so these checks never cold-import the scientific stack. The
757+
package dispatch deliberately precedes the JSON-native fast path: e.g. a
758+
package-defined subclass of dict still receives its relevant codec check.
759+
The remaining BridgeCodec value behaviors (numpy/pandas scalars, bytes, sets,
760+
complex rejection, NaN/Infinity) are applied later during JSON encoding by
761+
default_encoder.
756762
"""
757-
if is_numpy_array(obj):
758-
return serialize_ndarray(obj, force_json_markers=force_json_markers)
759-
if is_pandas_dataframe(obj):
760-
return serialize_dataframe(obj, force_json_markers=force_json_markers)
761-
if is_pandas_series(obj):
762-
return serialize_series(obj, force_json_markers=force_json_markers)
763-
if is_scipy_sparse(obj):
764-
return serialize_sparse_matrix(obj)
765-
if is_torch_tensor(obj):
766-
return serialize_torch_tensor(
767-
obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
768-
)
769-
if is_sklearn_estimator(obj):
770-
return serialize_sklearn_estimator(obj)
763+
package = type(obj).__module__.split('.', 1)[0]
764+
765+
if package == 'numpy' and 'numpy' in sys.modules:
766+
if is_numpy_array(obj):
767+
return serialize_ndarray(obj, force_json_markers=force_json_markers)
768+
elif package == 'pandas' and 'pandas' in sys.modules:
769+
if is_pandas_dataframe(obj):
770+
return serialize_dataframe(obj, force_json_markers=force_json_markers)
771+
if is_pandas_series(obj):
772+
return serialize_series(obj, force_json_markers=force_json_markers)
773+
elif package == 'scipy' and 'scipy.sparse' in sys.modules:
774+
if is_scipy_sparse(obj):
775+
return serialize_sparse_matrix(obj)
776+
elif package == 'torch' and 'torch' in sys.modules:
777+
if is_torch_tensor(obj):
778+
return serialize_torch_tensor(
779+
obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
780+
)
781+
elif package == 'sklearn' and 'sklearn.base' in sys.modules:
782+
if is_sklearn_estimator(obj):
783+
return serialize_sklearn_estimator(obj)
784+
785+
if isinstance(obj, (type(None), bool, int, float, str, dict, list, tuple)):
786+
return obj
787+
771788
pydantic_value = serialize_pydantic(obj)
772789
if pydantic_value is not _NO_PYDANTIC:
773790
return pydantic_value

src/runtime/pyodide-bootstrap-core.generated.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

test/python/test_bridge_core.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Regression tests for the shared subprocess/Pyodide bridge core."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
11+
RUNTIME_DIR = Path(__file__).parent.parent.parent / 'runtime'
12+
13+
14+
def test_plain_values_do_not_import_scientific_codecs() -> None:
15+
"""Plain JSON values must not cold-import optional scientific packages."""
16+
script = f"""
17+
import json
18+
import sys
19+
20+
sys.path.insert(0, {str(RUNTIME_DIR)!r})
21+
from tywrap_bridge_core import serialize
22+
23+
packages = ('numpy', 'pandas', 'scipy', 'torch', 'sklearn')
24+
before = {{package for package in packages if package in sys.modules}}
25+
for value in (1, 'plain', [1, 'two'], {{'nested': [3]}}):
26+
assert serialize(value, force_json_markers=True) is value
27+
after = {{package for package in packages if package in sys.modules}}
28+
print(json.dumps(sorted(after - before)))
29+
"""
30+
31+
completed = subprocess.run(
32+
[sys.executable, '-c', script],
33+
check=True,
34+
capture_output=True,
35+
text=True,
36+
)
37+
38+
assert json.loads(completed.stdout) == []

0 commit comments

Comments
 (0)