Studio: keep pandas out of the backend startup import graph by danielhanchen · Pull Request #8962 · unslothai/unsloth · GitHub
Skip to content

Studio: keep pandas out of the backend startup import graph - #8962

Merged
danielhanchen merged 10 commits into
mainfrom
perf-startup-pandas
Aug 18, 2026
Merged

Studio: keep pandas out of the backend startup import graph#8962
danielhanchen merged 10 commits into
mainfrom
perf-startup-pandas

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

import main pulls pandas on every Studio startup, through a route that never needs it at import time.

The Startup profile workflow shows the chain outright (run 31930823568, windows-latest):

main 7.284s
  routes 5.719s
    routes.data_recipe 2.308s
      routes.data_recipe.seed 2.259s
        data_designer_unstructured_seed.chunking 2.247s

routes/data_recipe/seed.py imports the unstructured seed plugin at module scope to decide whether unstructured seed support is available. uvicorn binds the socket only after import main finishes, so this is time the login screen does not exist.

OS import main pandas self time time to a healthy port
windows-latest 7.284s 1,325 ms 5.206s
macos-15 4.669s 901 ms 3.331s
ubuntu-latest 3.612s 247 ms 2.986s

Change

  • seed.py resolves the plugin through a _chunking() helper on first use. None still means "not installed", so the existing 500 and the raw-text fallback are unchanged.
  • chunking.py resolves pandas through a _pandas() helper instead of at module scope. Two of the four call sites already did their own guarded import pandas as pd with the same error message; those fold into the helper, so "pandas is required for unstructured seed processing" is written once and covers all four.

The route is the part that matters. Importing data_designer_unstructured_seed.chunking runs the package __init__ first, and that re-exports .config and .impl, which import the data designer engine, which imports pandas and pyarrow. So taking the import out of chunking.py alone leaves the whole cost in the graph on any install that actually has the plugin.

Measured with the plugin installed, python -X importtime -c "import main":

routes.data_recipe.seed cumulative import main
before 534 ms 1.879s
after 5 ms 1.520s

pandas and pyarrow no longer appear in the graph at all.

Behaviour

Unchanged in every case, checked in isolated venvs against main, not just against the previous commit here:

before after
plugin absent 500 Unstructured seed support not available same
plugin present, pandas absent 500, same message (the engine needs pandas too, so the probe fails the same way) same
both present upload, single and multi file preview, chunking, parquet cache same

Nothing about chunking, caching, parquet layout or the preview rows changes. A failed probe is remembered, so a preview call on an install without the plugin is not a fresh import attempt every time, which is what the module-scope import gave before.

Coverage

tests/test_startup_defers_torch.py already holds the "import main must not import heavy modules" invariant in a fresh interpreter. This adds pandas and pyarrow to that list and routes.data_recipe.seed to the per-module guard.

Those two are runtime guards, and they pass vacuously wherever the optional plugin is not installed, which is most CI jobs: the route falls back to "unavailable" and imports nothing. So there is also a source-level guard that parses seed.py and fails on a module-scope import of the plugin, which holds in either environment.

tests/test_data_recipe_seed.py covers the availability semantics: unavailable without the plugin on both preview entry points, probed once rather than per call, raw text fallback for the extractor, and the installed path still normalizing through the plugin.

Repeat calls

_pandas() holds the resolved module in a global rather than re-running the import
statement, measured over 2,000,000 calls with pandas already in sys.modules:

form per call
import pandas as pd inside the function 68.6 ns
module global with a None check 32.9 ns
plain module global (eager, for reference) 24.8 ns

Worth having here because it costs one branch, but it is worth being clear about the
scale: 36 ns against the 250 ms this same import costs the first time it runs on this
machine, and 1,325 ms in CI on Windows. The saving that matters in this PR is the cold
one, on startup. There are about 3,250 other function-level lazy imports in the backend
and converting them would be a large diff for a per-call saving that is below noise at
every one of those call sites, so this PR does not touch them.

Testing

  • Two isolated venvs, one with the plugin and the data designer engine installed, one with the same set and pandas removed. import main is pandas-free and pyarrow-free in both; the missing-plugin and missing-pandas paths return the same 500 they returned on main.
  • End to end with the plugin installed: two uploads, multi file preview round-robin across both sources, single file preview, and both parquet cache re-read branches (build_unstructured_preview_rows and materialize_multi_file_unstructured_seed).
  • _pandas() with pandas blocked: same RuntimeError, the global is not left poisoned, a later import recovers in the same process. 16 threads racing it all get one module object.
  • pytest tests/test_data_recipe_seed.py tests/test_startup_defers_torch.py tests/test_refactor_guard.py tests/test_text_io_encoding.py, ruff check on the changed files, git diff --check.

The Startup profile workflow runs on this path, so the three-platform numbers will be reported on this PR directly.

danielhanchen and others added 3 commits August 16, 2026 08:16
Importing data_designer_unstructured_seed.chunking runs the package __init__
first, and that re-exports .config and .impl, which import the data designer
engine, which imports pandas and pyarrow. Dropping the module-scope import
inside chunking.py therefore left the whole cost in import main's graph
wherever the plugin is installed: measured 534ms cumulative for
routes.data_recipe.seed, with pandas and pyarrow both loaded.

The route resolves the plugin through _chunking() on first use instead. None
still means "not installed", so the existing 500 and the raw-text fallback
are unchanged, and the failed probe is remembered rather than retried per
request.

The runtime guards in test_startup_defers_torch.py pass vacuously wherever
the optional plugin is not installed, which is most CI jobs, so add a
source-level guard that reads seed.py and fails on a module-scope import of
the plugin either way. test_data_recipe_seed.py covers the availability
cases: unavailable without the plugin on both preview entry points, probed
once, raw text fallback, and the installed path.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 16, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 283f43e4e6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

chunking = _chunking()

P2 Badge Offload the cold plugin import from the async upload route

When the plugin is installed and the first unstructured upload arrives, _chunking() synchronously imports the package—including the data-designer engine, pandas, and pyarrow—from inside upload_unstructured_file, which is an async def handler. The cold import measured by this change can therefore block uvicorn's event loop for seconds and delay unrelated requests; run the extraction/plugin resolution through asyncio.to_thread or make the handler synchronous so FastAPI dispatches it to its worker pool.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@oobabooga

Copy link
Copy Markdown
Member

Confirmed the mechanism: upload_unstructured_file is async def and calls _extract_text_from_file directly at seed.py:488, so on an install that has the plugin the first _chunking() resolves it on the event loop.

That call site is not from this change. git blame puts it at 5e184f5 (#8512), and the handler already runs _get_block_total_size, raw_path.write_bytes and, for pdf and docx, import pymupdf4llm plus the full to_markdown conversion on the loop. The other two _chunking() call sites are already off it: inspect_seed_upload is a sync def, so FastAPI dispatches it to the worker pool.

Cold, in a fresh interpreter: pandas 258ms, pyarrow 89ms, pymupdf4llm 91ms. The one-time plugin resolution is the same order as work the handler already does synchronously, and it replaces the 2.247s that every launch paid before uvicorn bound the port, whether or not the user ever opens unstructured seed.

Offloading the handler is worth doing, but it should cover the write, the directory scan and the conversion rather than the import alone, and it changes the route's concurrency, which this PR deliberately leaves alone. Keeping it separate.

@oobabooga

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 50a70bb226

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 524033d9f8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

raise ValueError(f"Unsupported file type: {ext}")

if normalize_unstructured_text is None:
chunking = _chunking()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move the lazy plugin import off the event loop

On the first unstructured upload, the async def upload_unstructured_file handler calls _extract_text_from_file synchronously, so this newly deferred _chunking() call imports the plugin, pandas, and pyarrow directly on uvicorn's event-loop thread. For a first .txt or .md upload, the documented multi-second import delay therefore stalls every concurrent request instead of merely delaying startup; resolve the plugin in a thread pool or otherwise keep the cold import outside the async handler.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thumbs down. The code observation is right and the impact does not reach the bar, and this is also a verbatim re-raise of a point already settled on this PR.

Settled already. The identical claim was posted as a top-level review on 50a70bb226 and answered there, after which the review round closed with "Didn't find any major issues" on that same commit. The only commits since are 2184ab73f, a merge from main, and 524033d9f, a pre-commit.ci pass touching only test_lan_access_settings.py, test_compile_caches_are_per_worker.py and test_no_test_shadows_another.py. seed.py:427 is byte-identical to the round that was already closed.

On the substance. Yes, the cold probe now runs on the loop. It is a sub-second one-shot in an async def handler that already blocks the loop longer, on pre-existing code this PR does not touch. Measured on Linux, py3.13, with the plugin and data-designer-engine installed, n=3 each:

merge base head
first .txt upload, async def handler 0.0006 s 0.541 s
event-loop stall on that upload 0.0056 s (idle baseline 0.0053 s) 0.546 s, once per process
every subsequent upload 0.00045 s 0.00050 s
import pymupdf4llm, already on that same loop on the merge base 1.027 s 1.027 s
extracted text normalized normalized, identical

Once per process, and half the cost of an import that has always sat on that loop ahead of it. No crash, no wrong result, no data loss.

Reachability, since only one of the three call sites is on the loop. _read_preview_rows_from_unstructured_file and _read_preview_rows_from_multi_files are reached only from inspect_seed_upload, a sync def route, which FastAPI dispatches to the threadpool. Only _extract_text_from_file is on the loop, via upload_unstructured_file, async def since dd283b0605 (#4468), which is where the direct call was already made.

On the remedy. asyncio.to_thread, or making the handler sync, would also move raw_path.write_bytes, _get_block_total_size and the pdf and docx conversion off the loop. That changes the route's concurrency for pre-existing work and is a separate change, correctly out of scope here.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

On the red check: (Python 3.13) in Backend CI is inherited from main, not produced by this branch.

The failing assertion is

FAILED studio/backend/tests/test_text_io_encoding.py::test_text_io_names_its_encoding[lan_access.py]
assert not ['lan_access.py:127: subprocess(text = True) without encoding']

studio/backend/lan_access.py and studio/backend/tests/test_text_io_encoding.py are both byte-identical between this branch and origin/main, neither is in this PR's file list, and the same job fails identically on main at e4631945. The fix belongs on main and is open as #9142.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 524033d9f8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member Author

Gate 1: is this solving a real problem, and does it break anything

Verified independently rather than taking the PR body's word for it, on Linux, py3.13, importing the seed route the way routes/__init__.py does at uvicorn startup. The plugin is put on the path from the in-repo source tree, medians of 7 fresh interpreters each.

after importing routes.data_recipe.seed merge base e46319458 head
pandas in sys.modules yes no
pyarrow in sys.modules yes no
modules loaded 1523 1081
median import wall clock 1.308 s 1.052 s

So the problem is real, and it is worse than the PR body claims. On the merge base the module-scope

try:
    from data_designer_unstructured_seed.chunking import (...)
except ImportError:
    ...

fails in an environment without the data_designer engine, and the except ImportError swallows it, so the feature reports itself unavailable anyway. But data_designer_unstructured_seed/__init__.py reaches .config and pulls pandas and pyarrow before it gets as far as the missing engine. The merge base therefore pays 442 extra modules and about a quarter second of startup, permanently resident in the uvicorn process, for a feature it then disables. That is the strongest case for this change and it was not being made.

Does it break the old path

No. Both import forms behave identically on both trees, checked in fresh interpreters, which matters here:

merge base head
from data_designer_unstructured_seed import chunking ModuleNotFoundError: No module named 'data_designer' same
from data_designer_unstructured_seed.chunking import normalize_unstructured_text same same
text returned when chunking is unavailable raw, unchanged raw, unchanged

Worth recording the trap, because it produced a false regression report on the way: run in one interpreter, the package-form import fails first and leaves enough behind that the submodule form then appears to succeed and normalize. In a fresh process it does not. Any conclusion about this module drawn from a reused interpreter is an artifact.

Stated gaps, not implied coverage

The data_designer engine is not installable in this environment, so the plugin-present path could not be exercised end to end here. What is proven is that the deferred resolution reaches exactly the same import statement, and that the not-installed path is byte-for-byte the same behaviour as before. Cross-platform is running on staging rather than the org queue.

@danielhanchen
danielhanchen merged commit 35672fc into main Aug 18, 2026
43 of 46 checks passed
@danielhanchen
danielhanchen deleted the perf-startup-pandas branch August 18, 2026 07:14
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