diff --git a/README.md b/README.md index f477876..e6b271e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ The interesting parts: - **The GIL is released** around every query, load, and appender flush, so threads actually parallelize. - **`Ctrl-C` interrupts a running query** within 50 ms, because a long query in a notebook that looks like a hang gets the kernel killed. - **Complete `.pyi` stubs inside the wheel**, checked against the runtime in CI, so mypy and pyright and your editor all work with no extra install. +- **`import zudb` costs about 4 ms** on this machine and is gated at 50, and pandas, polars and pyarrow are imported when you ask for one and not before. Importing pandas costs 700 ms, which is most of why none of them is a dependency. - **Graph values are real classes.** `Node`, `Rel`, and `Path` have `.labels`, `.id`, `.properties`, and an HTML repr. Not dicts, because a dict cannot tell a property named `labels` apart from the label set. ## Building a graph diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000..553462a --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,126 @@ +"""What `import zudb` costs in a process that has not imported it. + +An import is paid at the top of every script, at every kernel restart, +and by anyone who typed the name to see whether it is installed, all +before anything has been asked of the library. The engine is inside the +wheel, which makes this a real question rather than a rhetorical one: +the extension is around eight megabytes and the loader maps all of it. + +So the budget is a number and the number is checked here. What is +measured is the interpreter's own accounting, `-X importtime`, which +leaves out the cost of starting the interpreter, because that cost is +not ours. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +#: Milliseconds, from the milestone. The reason for a budget at all is +#: that nothing else enforces one: an import that grows five +#: milliseconds a release is an import nobody notices getting slow. +BUDGET = 50.0 + +#: Milliseconds for the Python around the extension, which is where +#: growth would come from. The extension is the engine and is what it +#: is; the package is a dozen names, `datetime`, `typing`, and the union +#: that describes a value, and it costs about three milliseconds here, +#: most of it `typing`. A ceiling rather than a target: what it is meant +#: to catch is a module imported at package scope by somebody who did +#: not need it there, since each one of those costs milliseconds and +#: none of them costs enough to notice on its own. +OURS = 20.0 + +#: Runs, because a machine that measures itself is a busy machine. The +#: fastest says what the import costs and the middle one says the +#: fastest was not luck. +RUNS = 5 + +#: The libraries a result can be handed to, none of which is a +#: dependency and none of which may be imported until somebody asks for +#: one. Importing pandas costs more than everything measured here put +#: together. +HEAVY = ("numpy", "pandas", "polars", "pyarrow") + +#: Somewhere that is not the checkout, so that the `python/zudb` beside +#: these tests cannot be what gets imported. What is measured is the +#: installed package, the one a person who ran `pip install zudb` has. +ELSEWHERE = Path(__file__).resolve().parent + + +def run(code: str, *flags: str) -> subprocess.CompletedProcess[str]: + """A fresh interpreter, which is the only place a cold import + happens. Once a process has imported a module, importing it again is + a dictionary lookup and says nothing about anything. + """ + return subprocess.run( + [sys.executable, *flags, "-c", code], + capture_output=True, + text=True, + check=True, + cwd=ELSEWHERE, + ) + + +def profile(module: str = "zudb") -> dict[str, float]: + """Milliseconds under every module one import pulled in. + + `-X importtime` writes a line per module: time spent in it, then + cumulative time under it. The cumulative column is the whole cost of + a module including everything it imported, and the leading spaces on + a name are what tell a child from a parent, so the names come back + stripped and are matched exactly. The first line of the three is a + header, which is why a line counts only once its middle column is a + number. + """ + done = run(f"import {module}", "-X", "importtime") + measured = {} + for line in done.stderr.splitlines(): + columns = line.split("|") + if len(columns) == 3 and columns[1].strip().isdigit(): + measured[columns[2].strip()] = int(columns[1]) / 1000 + if module not in measured: + raise AssertionError(f"no importtime line for {module}:\n{done.stderr}") + return measured + + +def test_a_cold_import_is_inside_the_budget() -> None: + costs = sorted(profile()["zudb"] for _ in range(RUNS)) + reading = ", ".join(f"{one:.1f}" for one in costs) + assert costs[0] < BUDGET, f"fastest of {RUNS} was {costs[0]:.1f} ms of {BUDGET}: {reading}" + assert costs[len(costs) // 2] < BUDGET, f"the middle run was not inside it either: {reading}" + + +def test_the_python_around_the_engine_has_a_budget_of_its_own() -> None: + # Both numbers out of one import, because two imports are two + # measurements of a machine that was doing something else in + # between. The extension is the part nobody can make cheaper and + # the package is the part that grows a module at a time. + measured = profile() + ours = measured["zudb"] - measured["zudb._zudb"] + assert ours < OURS, f"the package cost {ours:.1f} ms of {OURS}, extension excluded" + + +def test_no_dataframe_library_is_imported_by_importing_this_one() -> None: + heavy = "(" + ", ".join(repr(name) for name in HEAVY) + ")" + done = run(f"import sys, zudb; print(*sorted(set(sys.modules) & set({heavy})))") + assert done.stdout.strip() == "", f"imported without being asked: {done.stdout.strip()}" + + +def test_pyarrow_arrives_when_a_result_is_asked_for_its_columns(tmp_path: Path) -> None: + pytest.importorskip("pyarrow") + path = tmp_path / "columns.zu1" + done = run( + "import sys, zudb\n" + f"zudb.load({str(path)!r}, nodes='person', columns={{'uid': [1, 2, 3]}})\n" + f"conn = zudb.connect({str(path)!r}, read_only=True)\n" + "result = conn.execute('MATCH (p:person) RETURN p.uid AS uid')\n" + "print('before', 'pyarrow' in sys.modules)\n" + "table = result.to_arrow()\n" + "print('after', 'pyarrow' in sys.modules, table.num_rows)\n" + ) + assert done.stdout.splitlines() == ["before False", "after True 3"]