Repository navigation
perf(kernel): import pyarrow on the first result instead of in connect() (stacked on #1049) #1053
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
vuanhphung
wants to merge
14
commits into
main
Choose a base branch
from
perf/rd-connect-lazy-pyarrow
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
27479f9
perf: import pandas on first row conversion instead of in connect()
vuanhphung 4041f33
test: tell an import failure apart from pandas being loaded
vuanhphung 9b91115
test: skip the pandas import guard only when an optional extra is mis…
vuanhphung 1c07208
test: skip the pandas import guard when the kernel wheel is missing
vuanhphung 916638b
test: pin that _disable_pandas never imports pandas; bound subprocess…
vuanhphung c193711
test: guard the default Thrift connect path against importing pandas
vuanhphung a2b0a5f
perf(kernel): import pyarrow on the first result instead of in connect()
vuanhphung 5216929
test: fail, never skip, when a pyarrow import-guard module can't import
vuanhphung aafa88c
fix: raise AttributeError when the lazy pyarrow can't import
vuanhphung 55f7171
test: bound the pyarrow import-guard subprocesses
vuanhphung f77297c
test: let only the sentinel stop the first-result check
vuanhphung 66580de
perf: rebind pyarrow to the real module on first use
vuanhphung 517c4c8
fix: try a failed pyarrow import only once
vuanhphung 9770a69
fix: read pyarrow attributes from the live module through the stand-in
vuanhphung File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| """``pyarrow`` for modules that treat it as optional, imported on first use. | ||
|
|
||
| ``connect()`` imports these modules, but pyarrow is only needed once results are | ||
| read. Importing it also imports numpy, which starts OpenBLAS's thread pool (one | ||
| busy-waiting thread per core), so it shouldn't be part of connecting. | ||
|
|
||
| One case differs from importing pyarrow eagerly: when pyarrow is installed but | ||
| fails to import (e.g. a numpy ABI mismatch). These modules then treat it as | ||
| available and fail when an attribute is first used, while the Thrift and SEA | ||
| backends, which import pyarrow when they load, treat it as missing as before. | ||
| """ | ||
|
|
||
| import importlib | ||
| import importlib.util | ||
| import sys | ||
| import types | ||
| from typing import Any, Dict, List, Optional | ||
|
|
||
| # Module globals whose ``pyarrow`` is the stand-in, rebound on first use. | ||
| _NAMESPACES: List[Dict[str, Any]] = [] | ||
|
|
||
|
|
||
| class _LazyPyarrow(types.ModuleType): | ||
| """Stands in for the ``pyarrow`` module until an attribute is used. | ||
|
|
||
| The first use rebinds ``pyarrow`` to the real module in every registered | ||
| namespace still holding the stand-in, so reads on the hot path afterwards | ||
| are plain module attribute lookups. The stand-in keeps no copies: a read | ||
| through it always gets the live module's attribute, patches included. | ||
| """ | ||
|
|
||
| def __getattr__(self, name: str) -> Any: | ||
| # A failed import is tried once, as an eager import was: retrying | ||
| # would rerun pyarrow's (and numpy's) partial initialization each time. | ||
| error = self.__dict__.get("_import_error") | ||
| if error is None: | ||
| try: | ||
| module = importlib.import_module("pyarrow") | ||
| except ImportError as e: | ||
| self.__dict__["_import_error"] = error = e | ||
| if error is not None: | ||
| # AttributeError, so hasattr() and getattr(..., default) still work. | ||
| raise AttributeError(f"pyarrow could not be imported: {error}") from error | ||
| for namespace in _NAMESPACES: | ||
| if namespace.get("pyarrow") is self: | ||
| namespace["pyarrow"] = module | ||
| return getattr(module, name) | ||
|
|
||
|
|
||
| _LAZY_PYARROW = _LazyPyarrow("pyarrow") | ||
|
|
||
|
|
||
| def optional_pyarrow( | ||
| namespace: Optional[Dict[str, Any]] = None, | ||
| ) -> Optional[types.ModuleType]: | ||
| """``pyarrow``, or None when it isn't installed, without importing it. | ||
|
|
||
| Returns the real module if it is already imported, otherwise a stand-in that | ||
| imports it on first attribute access. Whether pyarrow is installed is decided | ||
| with ``find_spec``, so ``pyarrow is None`` checks behave as before. Pass the | ||
| caller's ``globals()`` to have its ``pyarrow`` replaced by the real module | ||
| once the stand-in is first used. | ||
| """ | ||
| module = sys.modules.get("pyarrow") | ||
| if module is not None: | ||
| return module | ||
| try: | ||
| installed = importlib.util.find_spec("pyarrow") is not None | ||
| except (ImportError, ValueError): | ||
| installed = False | ||
| if not installed: | ||
| return None | ||
| if namespace is not None: | ||
| _NAMESPACES.append(namespace) | ||
| return _LAZY_PYARROW | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| """pandas must load on the first row conversion, not when connect() is imported. | ||
|
|
||
| Each check runs in a fresh interpreter because ``sys.modules`` is process-global. | ||
| """ | ||
|
|
||
| import subprocess | ||
| import sys | ||
| import textwrap | ||
|
|
||
| import pytest | ||
|
|
||
| pytest.importorskip("pyarrow") | ||
|
|
||
| CONNECT_PATH_MODULES = [ | ||
| "databricks.sql", | ||
| "databricks.sql.client", | ||
| "databricks.sql.result_set", | ||
| "databricks.sql.utils", | ||
| "databricks.sql.backend.thrift_backend", | ||
| "databricks.sql.backend.kernel.client", | ||
| "databricks.sql.backend.kernel.result_set", | ||
| "databricks.sql.backend.sea.backend", | ||
| ] | ||
|
|
||
|
|
||
| def _run(script: str) -> subprocess.CompletedProcess: | ||
| return subprocess.run( | ||
| [sys.executable, "-c", textwrap.dedent(script)], | ||
| capture_output=True, | ||
| text=True, | ||
| timeout=120, | ||
| ) | ||
|
|
||
|
|
||
| def _table_ipc_hex() -> str: | ||
| # Children read the table from IPC bytes: building it from Python lists | ||
| # makes pyarrow import pandas itself, which would hide what the | ||
| # conversion imports. | ||
| import io | ||
|
|
||
| import pyarrow | ||
| import pyarrow.ipc | ||
|
|
||
| table = pyarrow.table({"a": [1, None], "b": ["x", "y"]}) | ||
| sink = io.BytesIO() | ||
| with pyarrow.ipc.new_stream(sink, table.schema) as writer: | ||
| writer.write_table(table) | ||
| return sink.getvalue().hex() | ||
|
|
||
|
|
||
| def _convert_rows_script(disable_pandas: bool) -> str: | ||
| return f""" | ||
| import sys | ||
| from unittest.mock import MagicMock | ||
| import pyarrow.ipc | ||
| from databricks.sql.result_set import ResultSet | ||
|
|
||
| table = pyarrow.ipc.open_stream(bytes.fromhex("{_table_ipc_hex()}")).read_all() | ||
| assert "pandas" not in sys.modules | ||
| rs = MagicMock() | ||
| rs.connection.disable_pandas = {disable_pandas} | ||
| rs.description = [("a",), ("b",)] | ||
| rows = ResultSet._convert_arrow_table(rs, table) | ||
| assert [tuple(r) for r in rows] == [(1, "x"), (None, "y")], rows | ||
| print("pandas" in sys.modules) | ||
| """ | ||
|
|
||
|
|
||
| # Sentinel exit codes, distinct from the 1 of an uncaught exception. | ||
| _NO_PANDAS, _PANDAS_LOADED, _MISSING_OPTIONAL_DEP = 10, 11, 12 | ||
| # Only a missing optional extra may skip; any other import error fails. | ||
| _OPTIONAL_DEPS = ("databricks_sql_kernel", "pyarrow") | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("module_name", CONNECT_PATH_MODULES) | ||
| def test_connect_path_does_not_import_pandas(module_name): | ||
| proc = _run(f""" | ||
| import sys | ||
| try: | ||
| import {module_name} # noqa: F401 | ||
| except ImportError as e: | ||
| # The kernel backend re-raises a missing wheel as a friendlier | ||
| # ImportError, so look for the missing extra along the chain. | ||
| cause = e | ||
| while cause is not None and not ( | ||
| isinstance(cause, ModuleNotFoundError) | ||
| and cause.name in {_OPTIONAL_DEPS!r} | ||
| ): | ||
| cause = cause.__cause__ or cause.__context__ | ||
| if cause is None: | ||
| raise | ||
| print(cause.name) | ||
| sys.exit({_MISSING_OPTIONAL_DEP}) | ||
| sys.exit({_PANDAS_LOADED} if "pandas" in sys.modules else {_NO_PANDAS}) | ||
| """) | ||
| if proc.returncode == _MISSING_OPTIONAL_DEP: | ||
| pytest.skip(f"{module_name} needs {proc.stdout.strip()}, not installed") | ||
| assert proc.returncode != _PANDAS_LOADED, f"importing {module_name} imported pandas" | ||
| assert ( | ||
| proc.returncode == _NO_PANDAS | ||
| ), f"unexpected exit {proc.returncode}:\n{proc.stderr}" | ||
|
|
||
|
|
||
| def test_row_conversion_imports_pandas_on_first_use(): | ||
| proc = _run(_convert_rows_script(disable_pandas=False)) | ||
| assert proc.returncode == 0, proc.stderr | ||
| assert proc.stdout.strip() == "True", "the pandas path should import pandas" | ||
|
|
||
|
|
||
| def test_disable_pandas_never_imports_pandas(): | ||
| proc = _run(_convert_rows_script(disable_pandas=True)) | ||
| assert proc.returncode == 0, proc.stderr | ||
| assert proc.stdout.strip() == "False", "_disable_pandas=True imported pandas" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.