* Studio: say which model is missing instead of "No model loaded" A /v1 request naming a model that is not downloaded returned the generic "No model loaded. Call POST /inference/load first.", which cannot fix it. Return 404 model_not_found naming the model and listing what can serve, and make the API usage examples name a model the server actually has. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: page the API monitor, show model load/unload, pin the example quant The monitor rendered all 50 retained entries in one scroller: page it 5 at a time, freezing history while paged back so live traffic cannot reorder it. Add model load/unload rows so the feed shows what the server is doing, and stop the header rendering the loaded model as a raw host path. Advertise each model's GGUF quant on /v1/models so the example pins repo:QUANT, and move the auto-switch section above the monitor with shorter copy. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: optionally download a model named in an OpenAI API request Auto-switch only ever loaded models already on disk, so naming one this server does not have either 404s or, when something else is loaded, gets quietly answered by the resident model. Add openai_api_auto_download_model (off by default, gated on auto-switch). When on, a /v1 request naming a GGUF repo that is not downloaded starts a background fetch and returns 503 with Retry-After and a typed model_downloading code. The resident model keeps serving in the meantime, and the retry after the download completes is served by the new model through the existing auto-switch path. The download reuses the Hub manager's service layer, which already does repo-id validation, casing, claim bookkeeping, disk preflight, resume and cancel. The in-loader download is deliberately not used: it silently falls back to a smaller quant under low disk, which is wrong when the caller named an exact one. Admission is narrow, since a request only needs an API key: - namespace/name only, so gpt-4 and other foreign ids fall through to the resident model exactly as before - GGUF only, decided from the remote file list rather than the repo name - anything declaring auto_map is refused, so trust_remote_code stays a deliberate opt-in in the UI and can never be granted over the API - a single download at a time, plus a free-disk reserve - one model_info call answers existence, gating and the quant list, so a missing repo, a gated repo and a wrong quant each get their own error With the setting off every one of these paths is byte-identical to before. Also: - monitor rows for downloads, with a live percentage - public_model_id resolves an HF cache snapshot to its repo id, so a cache-loaded model is no longer labelled with a commit sha; this drops the duplicate helper added for the monitor and fixes the same leak in the inference status response - the unedited sk-unsloth-YOUR_KEY from the copyable examples now says so instead of "Invalid or expired API key"; every other bad key keeps the generic message * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: add an Unload button to the API monitor The monitor names the loaded model but offered no way to free it. Idle auto-unload is the only existing release path, and it needs a TTL and a wait. The button sits next to Refresh, appears only while a model is loaded and is disabled mid-unload. /unload matches on the internal identifier, which this response deliberately omits because it would be a host path, so the click reads it from /api/inference/status the same way the chat runtime does rather than widening the monitor payload. Also stamp the manual unload row with the quant, read before the teardown clears it, so it reads repo:QUANT like the load row it pairs with. * Studio: keep the API monitor Unload button visible when idle It only rendered while a model was loaded, which hid the one manual release path at exactly the moment someone goes looking for it. Render it always, disabled with a "No model is loaded" tooltip when there is nothing to free. * Studio: never answer a named model with a different one Asking for a model this server is not serving returned 200 from whatever was resident. Requesting gemma-4-E2B-it-GGUF:UD-Q6_K_XL while UD-Q4_K_XL was loaded got a confident answer from the wrong quant, with nothing in the response saying so. A name carrying a namespace (org/model, optionally :QUANT) is a concrete reference, so 404 instead, with the reason: - wrong quant -> names the quants that are actually downloaded - not on disk -> lists what is available - on disk but auto-switch off -> says to turn it on Ids without a namespace (gpt-4, claude-3, default) are foreign labels rather than references, so they still fall through to the resident model and drop-in clients are unaffected. A bare org/model is still satisfied by any loaded quant of that repo; only an explicit :QUANT must match. The check runs whatever the auto-switch and auto-download toggles are, since serving the wrong weights is wrong in every configuration. It is skipped when nothing is loaded, where the existing no-model-loaded error already says the right thing, and when the model is on disk with auto-switch on, where a failed swap should still fall back. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: use a simpler prompt in the API usage examples "What is Unsloth Studio?" rather than "Can Unsloth Studio do API calling?". One constant feeds all nine snippet tabs. * Studio: only refuse a model reference meant for this server A namespace alone was treated as a concrete model reference, so a /v1 request naming anthropic/claude-3.5-sonnet, openai/gpt-4o or any other LiteLLM or OpenRouter style vendor/model id started returning 404 instead of being answered by the resident model. Refuse only on evidence the caller meant this server: an explicit GGUF quant label, or a repo that is actually on disk here. gpt-4 and vendor/model alike fall through again, while the wrong-quant and wrong-repo cases this PR exists for still refuse. Also from review: - Release the single download slot by object identity, not repo id. A stale watcher could clear a newer download of the same repo and let a second multi-GB fetch start alongside it. - Catch BaseException around admission: CancelledError is not an Exception, so a cancelled request stranded the slot for the process lifetime. - Honour the download service's accepted=False, which it returns without raising for a cross-variant conflict, instead of promising a download that was never dispatched. - Treat a failed status probe as unknown rather than idle, so a transient read cannot fail the monitor row and free the slot under a live worker. - Check gated repos with auth_check. The Hub serves metadata for a gated repo without granting its files, so the licence gate was being reported as the unrelated custom-code refusal. - Size the disk reserve from the download plan, which includes the mmproj and MTP companions the worker fetches with every quant. - Never fetch under the server's own HF token. The repo is named by whoever holds an API key, so the ambient token let that key pull the owner's private repos. - Refuse an explicit quant on a backend with no quant identity, gated on the suffix really being a quant so Ollama style :latest tags still match. - Raise instead of falling through when the diagnosis fails: the mismatch is already established by then, only the wording is uncertain. - Report a failed switch as 503 model_switch_failed rather than answering as the resident model. - Fail an open monitor row under the same lock as the check, so a finish landing in between cannot stamp an error onto a completed row. - Usage examples never emit a hardcoded model id: the catalog is tri-state and the panel asks for a model to be loaded instead of printing one the server cannot serve. It also refreshes when the loaded model changes. - Keep the monitor pager reachable while frozen entries expire. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: scope the auto-download 404 cache to the caller's credentials The Hub answers 404 for a private repo the caller cannot see, so caching that verdict per repo alone let one anonymous request mark a private repo unservable for everyone for the whole TTL. A later caller sending a valid X-Unsloth-HF-Token skipped the probe and fell through to the resident model instead of downloading what it asked for. Keyed on the repo id plus a digest of the token now, so the token itself is never held. Two more from the same review: - Clear the chat runtime checkpoint after unloading from the API monitor, as the chat eject flow already does. The store went on treating the freed checkpoint as loaded and the usage examples kept naming it. - Point gated and not-found callers at the X-Unsloth-HF-Token header. Automatic download deliberately ignores the server's own Hugging Face identity, so telling the user to add a token in Studio sent them round the same 403 forever. * Studio: tighten the comments added by this branch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: keep API auto-download off the server's Hugging Face identity Passing None for the caller's token was not anonymous. spawn_worker substitutes the backend's HF_TOKEN for a falsy one, and HfApi(token=None) falls back to a cached login, so a repo named by an API-key holder could still be fetched under the owner's Hub identity and land in the shared catalog. The metadata probe and auth_check now pass an explicit False, and dispatch threads allow_ambient_token=False so the worker stays anonymous too. The flag defaults to True, so the UI download path keeps the ambient fallback that private repos rely on. Three more from the same review: - Require an exact hf_variant match only when the suffix is really a quant. The llama.cpp branch still compared Ollama style :latest and :8b against the loaded quant and refused the resident model, which is the opposite of what looks_like_quant classifies them as. - Decode an HF cache repo id only when the models-- component is followed by snapshots. An ordinary directory whose name merely starts with models-- was being read as an encoded repo id. - Return the probing response before consulting the job registry when an adopted claim has no variant yet. A stale error on the whole-repo key could otherwise release the slot the first request's probe still holds, letting a second large download start beside it. * Studio: stop treating a namespace as what decides model intent The rule refused a reference only when it carried a namespace, which was wrong in both directions. vendor/model is how LiteLLM and OpenRouter name every provider, and a standalone or custom-folder GGUF is advertised without one, so asking for a path-free local id such as model-Q4_K_M was answered by whatever else happened to be resident. The slashless early return is gone and the same evidence test now applies to every id: an explicit quant, or a model that actually resolves here. gpt-4 and default still fall through because they are not local, not because of their shape. Also: - Recognise bits-per-weight quant labels. _extract_quant_label emits IQ4_XS-3.53bpw and the resolver and downloader both accept it, but _GGUF_KNOWN_QUANT_RE has no bpw group, so looks_like_quant rejected a reference the rest of the machinery understands. - Upper-case the synthetic names handed to _pick_best_gguf. Its preference tokens are upper case and matched case-sensitively, so a repo with lower-case filenames skipped the preference and took the first entry, which can be F16. - Only offer a downloaded but unloaded model as a runnable example when auto-switch is on. It is off by default, so the copied snippet hit the no-model-loaded error, which is the failure this branch exists to fix. The tool-passthrough cancel test stubbed asyncio.to_thread module-wide, so it cancelled at the first thread hop rather than the generation hop it means to test. Model resolution runs off the loop before the monitor row opens, so that stub now passes the resolver through. * Studio: tighten the comments added since the last pass * Studio: match a resident model through its resolver alias A manual load stores the model by its on-disk path while the resolver and /v1/models advertise it as publisher/model, so _loaded_satisfies could not recognise the alias. Reducing the resolution to a boolean then threw away the load path that would have proved the match, and the request was refused with 404 for a model the server was serving at that moment. Common for LM Studio models and custom-folder aliases. The resolved path is compared against the resident backend before anything is refused. Also: - Size disk admission on what is left to fetch. expected_bytes is the whole plan, so a resumed quant or a companion already pulled in by another quant was charged for twice and could 507 a download that fits. Cached blobs are subtracted through existing_blob_bytes, the same accounting the worker's own preflight does, and it falls open to the full size when no blob hashes are available. - Report a cancelled download as cancelled. The catch-all sent every state other than complete or idle through fail_open, so a deliberate cancel rendered as a download failure rather than the monitor's cancelled state. - Keep polling the servable ids while nothing is loaded. The poll settled as soon as auto-switch was on, so turning it back off left the examples naming an unloaded model until something else remounted the panel. * Studio: shorten the comments added in the last pass * Studio: keep the FLA fast-path tests hermetic across transformers versions _discover_fla_model_types scans the *installed* transformers for modeling files importing `from fla.`, so `models/qwen3_5/` only exists from transformers 5.x. The backend supports transformers>=4.51, and on a 4.x install the Qwen3.5 gate returns False, so 14 tests in test_training_worker_flash_attn.py silently exercised a no-op instead of the install path and failed their call-count assertions. Pin the discovered model_type set in those 14 tests, the same way test_hook_does_not_install_tilelang_for_model_outside_allowlist already pins it against newly added FLA model_types. Test-only change: the production gate and the _discover_fla_model_types unit tests are untouched. * Studio: keep the /v1 admission check off the model-scanning path The admission check added here runs on every /v1 request, including with auto-switch off, where the route used to return straight away. It called resolve_local_gguf, whose index is cached for 5s and otherwise rebuilt by walking ./models and every HF cache root, under a lock the next caller waits on. On an install with a large cache that scan measured 6.1s, longer than the TTL that is meant to amortise it, so steady traffic would keep rebuilding it. Answer from the last built index instead and never rebuild from the request path: a stale answer is fine here, since what is on disk barely moves and a finished download already invalidates the index. The first request, before any scan has completed, warms the index on a background thread and skips the check rather than blocking on it. That also makes the lookup a dict read, so it no longer needs handing to a thread. Cold resolve on this box goes from 6152495us to 0.4us, and the whole hook now costs the same for a foreign label as for the resident model. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: fix the admission hook's cold, stale and contended index paths Five review items, four of them on the admission hook added here. Skipping the check until the first scan lands also skipped explicit quant mismatches, so the first request after startup could ask for :Q8_0 while Q4_K_M was resident and be answered by it. The early return was redundant as well: with an empty index resolved is None and here is False, so the gate below already lets a bare name through and refuses an explicit quant, which is what the except branch has always concluded. Dropped it and index_is_built with it. index_is_built took _lock, which _index holds for the whole scan, so once a warm was running every later request blocked on the event loop for exactly as long as the scan it was there to avoid. The warm now has its own lock and reads the timestamp unlocked, which is safe because _scan is only ever rebound. Warming only when the index had never been built left a model fetched in the Hub UI, or dropped into a scan folder, invisible for the life of the process, since only the auto-download watcher calls invalidate_index. Warm on staleness too, and unconditionally, so it refreshes within a TTL without a scan on the request path. Rescanning is capped at a tenth of the scan's own duration: a big install takes longer to scan than the TTL, and warming on the TTL alone would keep a thread scanning continuously. An Ollama-style tag names no quant, so the resolver misses it and auto-download saw a model the resident one already answers to, then 404'd it for having no such quant. Return early when the loaded model satisfies the reference. Frontend: a cancelled download said "Model download failed", because the label collapsed everything non-completed into failure. The backend tests get an autouse fixture that stops the warm from walking the developer's real HF caches; that scan starved the loop under the timing sensitive streaming tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: make /v1/models and the admission hook agree on what is local Three review items, all on the seam between the catalog scan and the resolver index, which run on separate schedules. /v1/models can advertise a local GGUF the resolver has not indexed yet. A bare id carries no quant to refuse on, so a client asking for one it had just been handed was answered by the resident model instead. The hook now reads the catalog cache as evidence too, never scanning it. It takes the path rather than a yes/no because the converse also happens: the catalog can list the resident weights under an alias the loaded entry does not answer to, and those must stay served. That alias was also emitted twice by /v1/models, once as the loaded basename a manual load records and once as publisher/model marked unloaded, because the dedup only compared ids. Compare the path as well. A directly loaded standalone .gguf takes its quant from the filename, but the resolver stores such files with no quants, so the advertised <stem>:<quant> stopped resolving as soon as anything else loaded. Advertise a quant only when that reference resolves, and downgrade only on a definite answer so a cold index leaves the metadata alone. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten the comments this branch adds Collapse the multi-line notes in the auto-download path, the /v1 admission hook and their tests to one line each, keeping the reason and dropping the restatement. No behaviour change. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: four admission and catalog fixes from review Lowercasing paths in _resolves_to_resident made /srv/models/Foo and /srv/models/foo the same weights on any case-sensitive filesystem, so a request for one could be answered by the other and /v1/models could mark the wrong entry loaded. That helper now backs residency as well as admission, so use os.path.normcase, which folds case only where the filesystem does. Advertising a quant whenever the resolver could not disprove it kept the bug it was meant to fix: a standalone .gguf loaded before the first scan still got <stem>:<quant> published, and the usage examples persist that. No proof is not proof, so omit it and warm the index instead. A 401 from an expired or invalid X-Unsloth-HF-Token skipped the 403 and 404 branches and surfaced as "could not reach Hugging Face, retry shortly". It now says to replace the token, kept apart from the gated refusal since a rejected credential is not an unaccepted licence. An image request naming an undownloaded text-only GGUF started the whole download and only then hit the capability guard, which never sees a remote target, so every retry 400d and the bytes were wasted. Thread require_vision into admission and check it against the mmproj companions the disk preflight already asks build_gguf_variant_plans for. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: make the Hub error fixture carry a status on both hub majors The 401 test built HfHubHTTPError directly, which works on 0.x and fails on 1.x where response is required and keyword-only, so all four Python jobs failed while the same test passed locally. _hub_error already handled both constructors, but the 0.x branch left the exception with no response at all, and hf_error_status reads the status off it for the types that do not encode it in their name. So it could only produce a usable error on 1.x, which is why the test bypassed it. Attach the status when the constructed exception lacks it, and use the helper. Cover the helper itself against stand-ins for both constructor shapes, since whichever hub is installed only ever exercises one of them. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: invalidate on every download, resolve bare tags, keep polling Three review items. Only the API auto-download watcher dropped the resolver cache, so a GGUF fetched in the Hub UI stayed absent to the cache-only request path and the request was answered by whatever was resident. finalize_worker_exit is the one point every download worker exits through, so invalidate there. That closes the window without leaning on the TTL, which the scan-duration throttle can stretch past 5s on an install where the scan itself takes longer than that. A downloaded but unloaded GGUF asked for as org/model:latest missed the resolver, since the suffix was always treated as an exact quant. With auto-download on that probed the Hub and returned a 404 for a quant that was never a quant; with it off it refused without switching. Fall back to the base entry when the suffix is not quant-shaped, and keep exact matching for real quants so a swap can never serve the wrong weights under the right name. The usage examples stopped polling once a model was resident, but idle unload frees one without touching the store, so nothing re-ran the effect and the examples kept naming a model that could no longer be reloaded. Slow the poll to 60s instead of stopping it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: hold the download slot while it is in use, and keep quants to llama.cpp _loaded_satisfies refuses a quant reference against the Transformers backend by name, but the path match did not carry that rule. A Transformers model active from a directory that also holds GGUF exports therefore matched a request for one of those quants and answered it with the safetensors weights. Only llama.cpp has a quant identity, so admission now passes llama_only whenever the reference is quant-qualified. A bare name still matches either backend, and /v1/models residency keeps the default so a loaded Transformers model is still reported loaded. The 24 hour watch window was bounding ownership of the single-flight slot when it should only have been bounding progress reporting, so a legitimately slow download had its slot handed back while the worker was still writing, admitting a second multi-gigabyte download beside it. Resolve the row on the clock, but keep the slot on a slower poll until the job is actually terminal. Past the deadline an unknown state does release it, since it means the worker cannot be probed and holding it on that forever would wedge auto-download. * Studio: keep what the resolver already knew when a download lands Invalidating cleared the index to empty. The request path reads that cache without scanning, so from a completed download until the rebuild landed it had no evidence about any local model, not just the new one, and a bare request for any of them was answered by whatever was resident. Wiring the hook into the shared completion path in the last commit widened that from auto-download to every download. Mark the scan stale and keep the entries instead. Both _index and warm_index_soon rebuild on a zero stamp, while the request path still sees everything it knew a moment ago. Only a completed download invalidates, and that only ever adds models, so nothing retained goes false. Warm from the completion hook too, so the rebuild starts when the download lands rather than when the next request happens to need it. * Studio: match the quant, not just the directory, and default-select bare tags Two quants of one repo share a directory, so the path match could not tell them apart and an explicit :Q8_0 was answered by a resident Q4_K_M that _loaded_satisfies had already refused by name. The llama_only fix in the last commit only ruled out the wrong backend, not the wrong quant on the right one. Both path matches now require the resident hf_variant to equal the requested quant whenever the reference is quantified; a bare name still matches on the path alone, since it claims nothing about the weights. The local resolver already treated a tag that names no quant as meaning the repo, but remote admission still looked for a quant literally called "latest", so the same reference resolved locally and 404d remotely. Branch on looks_like_quant there too. A real quant the repo does not have is still a 404 and never a substitution, which is what separates this from the loader's low-disk fallback. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: one quant preference, and stop trusting a stale checkpoint list_local_gguf_variants sorts by descending size, so the head of variants was the biggest quant, often F16, while remote admission and a plain load both rank through _pick_best_gguf. A bare id therefore meant a different quant depending on which side answered it, and the local answer was the one that could evict a working model and then fail or OOM starting an F16 next to a usable Q4. /v1/models advertised that same head for pinning. Pull the ranking into one preferred_quant helper and have both sides use it. The usage examples returned a stored checkpoint without ever consulting /v1/models, and the polling added last round was gated on not having one, so for a stored checkpoint it never ran. An idle unload then left the panel showing a snippet that could not run. Poll whenever mounted, and prefer the checkpoint only while the catalog still backs it or switching can reload it. A catalog that has not answered yet is not evidence against it. The static contract pinned the old dependency array, so it now asserts the intent it documents: a finished load re-runs the fetch, and the effect is not gated on having no checkpoint. * Studio: fix the Windows path compare, and advertise a label the worker knows The case fix normalized the separator to "/" and then called os.path.normcase, which on Windows folds case and rewrites the separator back to a backslash, so the descendant checks compared against a "/" the path no longer had. A manually loaded GGUF reached through an alias then read as a different model, giving a false 404 and an alias marked unloaded. Run normcase first and normalize the separator after it. There are two quant-label extractors and they only agree while a recognized quant token is present. With none, _extract_quant_label takes the last hyphenated segment, "7b" of llama-7b, while build_gguf_variant_plans and the worker key the whole stem: the plan lookup missed and the job exited on a variant it had no shards for. Use the canonical extractor for the unrecognized case only. Checked across real filenames first, the two match on every recognized quant and part on bpw-qualified labels, which _extract_quant_label keeps apart on purpose so byteshape's IQ4_XS at 3.53, 3.97 and 4.19 bpw stay separate variants. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: a stored checkpoint needs catalog evidence, not just the switch setting Preferring it whenever switching was on short-circuited the catalog check, so a checkpoint the store still held after the model was deleted or moved kept being named even though /v1/models had already proved it absent, and the snippets 404d instead of falling back to a model that is actually there. A lookup rather than a disjunction, which settles the whole matrix in one place: no answer yet keeps the checkpoint, since that is not evidence against it; listed and resident keeps it; listed but unloaded keeps it only when switching can reload it; absent falls back whatever the setting says. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: normalize the quote style pre-commit would have rewritten * Studio: cover the model that just landed, and pin the quant the catalog has Retaining the index on invalidation protects what was already scanned and by construction cannot contain the model that just finished downloading, so a bare request for it in the window before the rebuild was still answered by the resident model. Record the repo at the completion hook and treat that as admission evidence alongside the resolver and the catalog; the next completed scan clears the notes, since the index then covers them. Publishing a rebuilt index before completion becomes observable would have closed it too, but that blocks the download worker for the length of the scan. Catalog membership proves the repo, not the saved quant, and the examples then pinned the stored one. A quant deleted while another quant of the same repo remained produced repo:deleted-quant, a missing-quant 404 with a runnable alternative listed right beside it. Pin what the catalog advertises: for a resident entry that is the resident quant, for an unloaded one it is a quant actually on disk. The store is only consulted before /v1/models has answered. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: apply three rules everywhere they belong, not only where reported The trust probe was the last credential handoff still passing a raw token. huggingface_hub reads None as "use the cached login", so a caller-named repo was read with this server's Hugging Face identity whenever the caller sent none, which is exactly the isolation the metadata probe and the worker already keep. It takes _hub_token now. Enumerated the rest of that path while there: auth_check, model_info and spawn_worker were already correct. finalize_worker_exit is shared with dataset downloads, so the resolver hook fired for every completed dataset, scanning the model directories for nothing and recording the dataset id as local-model evidence, which turns a bare /v1 request naming that id into a refusal instead of a foreign-id fallthrough. Gated on repo_type. _already_serving decided "bare" on the presence of a colon while _loaded_satisfies and the resolver decide it on whether the suffix names a quant, so org/model:latest against a serving Q8_0 read as a mismatch and swapped in the preferred Q4_K_M for a request either one answers. That rule now lives in four places, each fixed in its own round, so this time I looked for the rest and found a fifth: describe_local_miss splits on the bare colon and its docstring claims it splits like the resolver. It no longer did, and would report a missing quant named "latest". Fixed here too, unreported. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: probe before refusing busy, and scan once when the index is cold The busy refusal fired before anything established the requested label was a model at all, so any namespaced id a drop-in client sends was told to wait out an unrelated download for as long as it ran. Probe first and refuse only a label the Hub actually serves as GGUF; anything else falls through to the resident model as before. A probe failure answers "not downloadable", since stranding ordinary traffic costs more than missing a busy refusal. Treating an unbuilt index as "nothing here" let the first request after startup be answered by the resident model under another model's name. That was a deliberate trade to keep the scan off the request path, and it was the wrong one. Cold, the scan now runs once on a thread, bounded so a pathological install falls through rather than hanging the request. Built, the request path still never scans, so the latency fix stands. The watcher freed the slot the moment it saw an error, while Retry-After is thirty times the poll interval, so the client came back to an empty slot and restarted the same failing download instead of being told. Hold the failure on the slot until a retry surfaces it, and let another repo take it after three retry intervals so a client that never returns cannot keep it. The watcher also invalidated on completion, which now lands after finalize_worker_exit's warm and marks that fresh scan stale, pushing a synchronous rescan onto the retry. Removed. _loaded_satisfies lowercased paths as well as aliases, so it returned satisfied before the case-preserving compare below could run. Both now go through one helper: paths compare with normcase, aliases stay case-insensitive. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: an unfinished scan is not absence, and a decided refusal is not a failure Bounding the cold scan then reading the bound as "not here" left the same hole one branch over. A timeout now answers 503 model_indexing with a Retry-After and leaves the warm running. A foreign label sent inside that window is asked to retry rather than falling through, which is a real cost, but the window is one request on an install whose scan exceeds ten seconds and it clears itself, where answering with the wrong weights does not. That uncovered a worse one. Every check here runs inside a broad except whose job is "could not verify, so fall through", so an HTTPException raised in the block was logged as a verification failure and the request was answered by the resident model. Any refusal decided in there was being swallowed. Re-raise it ahead of that handler. Canonicalizing generic labels made them real variant keys, but the matcher still decided on shape, so repo:llama-13b fell past an exact match and fetched llama-7b. Match exactly first, whatever the shape; a quant-shaped suffix that matches nothing is still a miss and never a swap. Marking a catalog alias loaded while publishing the preferred on-disk quant claimed alias:Q4 was loaded while Q8 was serving, and requiring the resident quant to match then made pinning it a 404. Advertise the resident variant when the entry resolves to the resident model. * Studio: keep the asyncio.timeout fallback tests runnable on Python 3.10 Both tests deleted asyncio.timeout to force _wall_clock_timeout down its pre-3.11 branch, but monkeypatch.delattr raises when the attribute is already absent. On Python 3.10, the one version the fallback exists for, there is nothing to delete, so the two tests errored with AttributeError before reaching the code they cover. Passing raising=False makes the deletion a no-op there and leaves the assertions running against the same branch on every version. Every other delattr in the repo already passes raising=False for exactly this reason. Verified with asyncio.timeout removed from the interpreter: the two tests fail with the CI AttributeError before this change and pass after, and the file still runs 89 passed on 3.13 where the deletion is real. * Studio: decide GGUF residency, servability and variant keys by one rule each Four admission and catalog fixes, each closing a gap between two places that were answering the same question differently. The /v1/models catalog asked _resolves_to_resident without llama_only, so a Transformers model live from a directory that also holds GGUF exports marked a GGUF alias loaded and gave it a GGUF quant. The usage examples then pinned alias:quant that nothing could serve with switching off. Every entry in that loop is advertised as GGUF, so residency there is llama.cpp residency. The busy probe accepted any .gguf sibling while admission excludes mmproj, MTP drafters and big-endian builds. A repo holding only companions is not downloadable, so it was held at model_download_busy for the length of an unrelated download instead of falling through to the resident model as it does when no download is running. It now reuses _gguf_variants, the same filter. split_model_ref refused any slash-bearing suffix, but an unrecognized GGUF below a subdirectory keys on its path (build/llama-13b), which is_valid_gguf_variant allows and the catalog advertises. Pinning such a variant could not parse, so only the default-ranked one was reachable. A slash-bearing suffix is now a variant exactly when a real Hub repo precedes it, which still leaves C:/models/x.gguf a path rather than a quant. The usage examples treated a downloaded-but-unloaded model as runnable only under auto-switch, but a standalone UNSLOTH_MODEL_IDLE_TTL reloads exactly what it freed on the next request. The panel hid runnable examples after an idle unload. Tracked apart from auto-switch, because the stash restores the stored checkpoint only and never an arbitrary catalog entry. Also stub the index walk in the three cold-index tests that missed it: a real multi-root scan inside the cold-wait budget made them time out into a 503 under load rather than assert what they are there for. One of them flaked locally. Verified each fix is load-bearing by reverting it and watching its test fail. Backend CI command: 10195 passed, 0 failed. tsc -b and the frontend build clean. * Studio: bound the Hub admission probes and stop guessing at nested model paths Three review fixes plus a test-isolation one. _resolves_to_resident matched on a path prefix, so two separately indexed models that nest (/models/A alongside /models/A/sub/B) both satisfied it: loading B made a request for A resident and answered it with B's weights, and the catalog marked A loaded. A prefix match now counts only when no catalog entry sits deeper, which is the innermost indexed model that actually owns the file. With nothing indexed there is no nesting to tell apart, so the directory-to-weights match this exists for is unchanged. auth_check and hf_hub_download take no timeout of their own, and both ran while the provisional single-flight slot was held, so an unresponsive Hub stalled the request far past the metadata budget and reported every other model busy for the duration. Both are bounded now. Each default errs the safe way: an unchecked repo is not a cleared one, so the custom-code probe refuses on timeout, while a slow gated-repo check stays inconclusive because the download's own auth is the real gate. The usage examples caught a failed refresh into an empty catalog and a disabled auto-switch, which made a transient error authoritative and blanked every example while the model was still servable. The catalog is deliberately tri-state; a failure now keeps the last answer and retries. Also start the backend tests from a built, empty model index. Stubbing only the background warm still left the cold path walking real caches synchronously inside the admission wait, so on a large install a test asserted against a 503 "still indexing" instead of its subject. _build_index is untouched, so the tests that call it directly still exercise the real walk. Verified each fix is load-bearing by reverting it and watching its test fail. tsc -b clean. Backend CI command green apart from two failures reproduced only on this box (a real model-dir scan and an orphan-process cleanup), neither touched by this PR; staging CI is the gate for those. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1664 lines
65 KiB
Python
1664 lines
65 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
from __future__ import annotations
|
|
|
|
import builtins
|
|
import subprocess
|
|
import sys
|
|
from typing import Any
|
|
from unittest import mock
|
|
|
|
from core.training import worker
|
|
|
|
|
|
def _missing_flash_attn_import():
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(
|
|
name,
|
|
globals = None,
|
|
locals = None,
|
|
fromlist = (),
|
|
level = 0,
|
|
):
|
|
if name == "flash_attn":
|
|
raise ImportError
|
|
return real_import(name, globals, locals, fromlist, level)
|
|
|
|
return fake_import
|
|
|
|
|
|
def _missing_module_import(missing: str):
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(
|
|
name,
|
|
globals = None,
|
|
locals = None,
|
|
fromlist = (),
|
|
level = 0,
|
|
):
|
|
if name == missing:
|
|
raise ImportError
|
|
return real_import(name, globals, locals, fromlist, level)
|
|
|
|
return fake_import
|
|
|
|
|
|
def test_should_try_runtime_flash_attn_install_threshold_and_skip(monkeypatch):
|
|
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
|
|
assert worker._should_try_runtime_flash_attn_install(32767) is False
|
|
assert worker._should_try_runtime_flash_attn_install(32768) is sys.platform.startswith("linux")
|
|
|
|
monkeypatch.setenv(worker._FLASH_ATTN_SKIP_ENV, "1")
|
|
assert worker._should_try_runtime_flash_attn_install(32768) is False
|
|
|
|
|
|
def test_runtime_flash_attn_prefers_prebuilt_wheel(monkeypatch):
|
|
statuses: list[str] = []
|
|
|
|
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"flash_attn_wheel_url",
|
|
lambda env: "https://example.com/fa.whl",
|
|
)
|
|
monkeypatch.setattr(worker, "url_exists", lambda url: True)
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"_send_status",
|
|
lambda queue, message: statuses.append(message),
|
|
)
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"install_wheel",
|
|
lambda *args, **kwargs: [("pip", subprocess.CompletedProcess(["pip"], 0, ""))],
|
|
)
|
|
|
|
worker._ensure_flash_attn_for_long_context(event_queue = [], max_seq_length = 32768)
|
|
|
|
assert statuses == ["Installing flash-attn for faster training..."]
|
|
|
|
|
|
def test_runtime_flash_attn_falls_back_to_pypi(monkeypatch):
|
|
calls: list[list[str]] = []
|
|
statuses: list[str] = []
|
|
|
|
monkeypatch.delenv(worker._FLASH_ATTN_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(builtins, "__import__", _missing_flash_attn_import())
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"probe_torch_wheel_env",
|
|
lambda timeout = 30: {
|
|
"python_tag": "cp313",
|
|
"torch_mm": "2.10",
|
|
"cuda_major": "13",
|
|
"cxx11abi": "TRUE",
|
|
"platform_tag": "linux_x86_64",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"flash_attn_wheel_url",
|
|
lambda env: "https://example.com/fa.whl",
|
|
)
|
|
monkeypatch.setattr(worker, "url_exists", lambda url: False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: None)
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"_send_status",
|
|
lambda queue, message: statuses.append(message),
|
|
)
|
|
monkeypatch.setattr(worker, "install_wheel", mock.Mock())
|
|
|
|
def fake_run(
|
|
cmd,
|
|
stdout = None,
|
|
stderr = None,
|
|
text = None,
|
|
):
|
|
calls.append(list(cmd))
|
|
return subprocess.CompletedProcess(cmd, 0, "")
|
|
|
|
monkeypatch.setattr(worker._sp, "run", fake_run)
|
|
|
|
worker._ensure_flash_attn_for_long_context(event_queue = [], max_seq_length = 32768)
|
|
|
|
assert statuses == ["Installing flash-attn from PyPI for long-context training..."]
|
|
assert calls == [[sys.executable, "-m", "pip", "install", "flash-attn"]]
|
|
|
|
|
|
def test_runtime_flash_attn_skip_env_avoids_all_install_work(monkeypatch):
|
|
monkeypatch.setenv(worker._FLASH_ATTN_SKIP_ENV, "1")
|
|
monkeypatch.setattr(worker._sp, "run", mock.Mock())
|
|
|
|
worker._ensure_flash_attn_for_long_context(event_queue = [], max_seq_length = 32768)
|
|
|
|
worker._sp.run.assert_not_called()
|
|
|
|
|
|
def test_causal_conv1d_fast_path_preserves_wheel_first_install_args(monkeypatch):
|
|
install_mock = mock.Mock(return_value = True)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
|
|
|
|
worker._ensure_causal_conv1d_fast_path(
|
|
event_queue = [],
|
|
model_name = "tiiuae/Falcon-H1-0.5B-Instruct",
|
|
)
|
|
|
|
install_mock.assert_called_once_with(
|
|
event_queue = [],
|
|
import_name = "causal_conv1d",
|
|
display_name = "causal-conv1d",
|
|
pypi_name = "causal-conv1d",
|
|
pypi_version = worker._CAUSAL_CONV1D_PACKAGE_VERSION,
|
|
filename_prefix = "causal_conv1d",
|
|
release_tag = worker._CAUSAL_CONV1D_RELEASE_TAG,
|
|
release_base_url = "https://github.com/Dao-AILab/causal-conv1d/releases/download",
|
|
)
|
|
|
|
|
|
def test_causal_conv1d_fast_path_includes_qwen3_6_variants(monkeypatch):
|
|
install_mock = mock.Mock(return_value = True)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
|
|
|
|
worker._ensure_causal_conv1d_fast_path(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.6-4B",
|
|
)
|
|
worker._ensure_causal_conv1d_fast_path(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3_6-4B",
|
|
)
|
|
|
|
assert install_mock.call_count == 2
|
|
|
|
|
|
def test_mamba_ssm_path_preserves_wheel_first_install_args(monkeypatch):
|
|
install_mock = mock.Mock(return_value = True)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", install_mock)
|
|
|
|
worker._ensure_mamba_ssm(
|
|
event_queue = [],
|
|
model_name = "tiiuae/Falcon-H1-0.5B-Instruct",
|
|
)
|
|
|
|
install_mock.assert_called_once_with(
|
|
event_queue = [],
|
|
import_name = "mamba_ssm",
|
|
display_name = "mamba-ssm",
|
|
pypi_name = "mamba-ssm",
|
|
pypi_version = worker._MAMBA_SSM_PACKAGE_VERSION,
|
|
filename_prefix = "mamba_ssm",
|
|
release_tag = worker._MAMBA_SSM_RELEASE_TAG,
|
|
release_base_url = "https://github.com/state-spaces/mamba/releases/download",
|
|
)
|
|
|
|
|
|
def _force_missing_fla_imports(monkeypatch):
|
|
"""Force fla.modules / fla.ops imports to raise ImportError."""
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *a, **kw):
|
|
if name.startswith("fla.modules") or name.startswith("fla.ops"):
|
|
raise ImportError
|
|
return real_import(name, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
|
|
|
|
def _pin_fla_model_types(monkeypatch):
|
|
"""Pin the auto-discovered FLA allowlist to the Qwen GDN families.
|
|
|
|
`_discover_fla_model_types` scans the *installed* transformers for modeling
|
|
files importing `from fla.`, and `models/qwen3_5/` only exists from
|
|
transformers 5.x. The backend supports `transformers>=4.51`, so on a 4.x
|
|
install the gate returns False and every Qwen3.5 assertion below silently
|
|
passes through a no-op instead of exercising the install path. Pinning keeps
|
|
these tests hermetic across the whole supported transformers range, the same
|
|
way test_hook_does_not_install_tilelang_for_model_outside_allowlist pins it
|
|
against newly added FLA model_types.
|
|
"""
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"_discover_fla_model_types",
|
|
lambda: frozenset({"qwen3_5", "qwen3_5_moe", "qwen3_6", "qwen3_next"}),
|
|
)
|
|
|
|
|
|
def test_flash_linear_attention_installs_pinned_pair_for_qwen3_5(monkeypatch):
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
_force_missing_fla_imports(monkeypatch)
|
|
statuses: list[str] = []
|
|
monkeypatch.setattr(worker, "_send_status", lambda queue, msg: statuses.append(msg))
|
|
|
|
worker._ensure_flash_linear_attention(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_called_once()
|
|
args = run_mock.call_args[0][0]
|
|
assert f"flash-linear-attention=={worker._FLA_PACKAGE_VERSION}" in args
|
|
assert f"fla-core=={worker._FLA_CORE_PACKAGE_VERSION}" in args
|
|
assert "--no-deps" in args
|
|
assert run_mock.call_args.kwargs["timeout"] == worker._TILELANG_INSTALL_TIMEOUT_S
|
|
assert any("flash-linear-attention" in s for s in statuses)
|
|
|
|
|
|
def test_flash_linear_attention_skips_for_unrelated_models(monkeypatch):
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
|
|
worker._ensure_flash_linear_attention(
|
|
event_queue = [],
|
|
model_name = "meta-llama/Llama-3.2-1B-Instruct",
|
|
)
|
|
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_flash_linear_attention_skips_for_ssm_only_models(monkeypatch):
|
|
# Nemotron-H / Falcon-H1 / Granite-H / LFM2 take the mamba_ssm path,
|
|
# never FLA's gated_delta_rule kernels.
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
|
|
for name in (
|
|
"tiiuae/Falcon-H1-0.5B-Instruct",
|
|
"nvidia/Nemotron-H-8B-Base",
|
|
"ibm-granite/granite-4.0-h-tiny",
|
|
"LiquidAI/LFM2-1.2B-Instruct",
|
|
):
|
|
worker._ensure_flash_linear_attention(event_queue = [], model_name = name)
|
|
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_flash_linear_attention_matches_full_qwen3_family(monkeypatch):
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
_force_missing_fla_imports(monkeypatch)
|
|
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
|
|
# Hermetic discovery: pretend transformers ships all Qwen GDN families.
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"_discover_fla_model_types",
|
|
lambda: frozenset({"qwen3_5", "qwen3_5_moe", "qwen3_6", "qwen3_next"}),
|
|
)
|
|
|
|
for name in (
|
|
"unsloth/Qwen3.5-2B",
|
|
"unsloth/Qwen3_5-MoE-A22B",
|
|
"unsloth/Qwen3.6-4B",
|
|
"unsloth/Qwen3_6-4B",
|
|
"unsloth/Qwen3-Next-80B-A3B",
|
|
"unsloth/Qwen3_Next-80B-A3B",
|
|
):
|
|
worker._ensure_flash_linear_attention(event_queue = [], model_name = name)
|
|
|
|
assert run_mock.call_count == 6
|
|
|
|
|
|
def test_flash_linear_attention_skipped_below_python_3_10(monkeypatch):
|
|
# sys.version_info is a structseq, not constructible; substitute a
|
|
# plain tuple so the `< _FLA_MIN_PYTHON` comparison still works.
|
|
monkeypatch.setattr(worker.sys, "version_info", (3, 9, 0, "final", 0))
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
|
|
worker._ensure_flash_linear_attention(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_flash_linear_attention_skipped_via_env(monkeypatch):
|
|
monkeypatch.setenv(worker._FLA_SKIP_ENV, "1")
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
|
|
worker._ensure_flash_linear_attention(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_flash_linear_attention_skipped_below_torch_2_7(monkeypatch):
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker, "_installed_torch_version_tuple", lambda: (2, 5))
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
statuses: list[str] = []
|
|
monkeypatch.setattr(worker, "_send_status", lambda queue, msg: statuses.append(msg))
|
|
|
|
worker._ensure_flash_linear_attention(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_not_called()
|
|
assert any("torch>=" in s for s in statuses)
|
|
|
|
|
|
def test_flash_linear_attention_install_includes_einops(monkeypatch):
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
monkeypatch.setattr(worker, "_installed_torch_version_tuple", lambda: (2, 9))
|
|
monkeypatch.setattr(worker, "_flash_linear_attention_importable", lambda: False)
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
|
|
|
|
worker._ensure_flash_linear_attention(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
args = run_mock.call_args[0][0]
|
|
assert "--no-deps" in args
|
|
# packaging and triton are added because fla/utils.py imports them at load
|
|
# but neither is in fla-core's METADATA (an upstream FLA gap).
|
|
assert "einops" in args
|
|
assert "packaging" in args
|
|
assert "triton" in args
|
|
assert f"flash-linear-attention=={worker._FLA_PACKAGE_VERSION}" in args
|
|
assert f"fla-core=={worker._FLA_CORE_PACKAGE_VERSION}" in args
|
|
|
|
|
|
def test_flash_linear_attention_logs_post_install_import_failure(monkeypatch):
|
|
"""pip exits 0 but `import fla.modules` still fails (missing transitive)."""
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
monkeypatch.setattr(worker, "_installed_torch_version_tuple", lambda: (2, 9))
|
|
import_calls = {"count": 0}
|
|
|
|
def fake_importable():
|
|
import_calls["count"] += 1
|
|
# Pre-install probe -> False (attempt install); post-install
|
|
# verify -> still False.
|
|
return False
|
|
|
|
monkeypatch.setattr(worker, "_flash_linear_attention_importable", fake_importable)
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
statuses: list[str] = []
|
|
monkeypatch.setattr(worker, "_send_status", lambda queue, msg: statuses.append(msg))
|
|
|
|
worker._ensure_flash_linear_attention(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
assert import_calls["count"] == 2
|
|
assert any("not importable" in s for s in statuses)
|
|
|
|
|
|
def test_tilelang_backend_skipped_on_unsupported_linux_arch(monkeypatch):
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.sys, "platform", "linux")
|
|
import platform as _platform
|
|
|
|
monkeypatch.setattr(_platform, "machine", lambda: "ppc64le")
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
|
|
worker._ensure_tilelang_backend(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_tilelang_backend_pins_only_binary(monkeypatch):
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
monkeypatch.setattr(worker, "_installed_tvm_ffi_version", lambda: None)
|
|
monkeypatch.setattr(worker, "_tilelang_importable", lambda: False)
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
|
|
# Bypass the post-install probe too.
|
|
probe_calls = {"count": 0}
|
|
|
|
def fake_probe():
|
|
probe_calls["count"] += 1
|
|
# Pre-install probe: False (install runs); post-install: True
|
|
# (success branch taken).
|
|
return probe_calls["count"] > 1
|
|
|
|
monkeypatch.setattr(worker, "_tilelang_importable", fake_probe)
|
|
|
|
worker._ensure_tilelang_backend(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
args = run_mock.call_args[0][0]
|
|
assert "--only-binary=:all:" in args
|
|
assert "--no-deps" not in args
|
|
|
|
|
|
def _force_missing_tilelang_imports(monkeypatch):
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *a, **kw):
|
|
if name in ("tilelang", "tvm_ffi"):
|
|
raise ImportError
|
|
return real_import(name, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
|
|
|
|
def test_tilelang_backend_installs_pinned_pair_for_qwen3_5(monkeypatch):
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
monkeypatch.setattr(worker, "_installed_tvm_ffi_version", lambda: None)
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
_force_missing_tilelang_imports(monkeypatch)
|
|
statuses: list[str] = []
|
|
monkeypatch.setattr(worker, "_send_status", lambda queue, msg: statuses.append(msg))
|
|
|
|
worker._ensure_tilelang_backend(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_called_once()
|
|
args = run_mock.call_args[0][0]
|
|
assert f"apache-tvm-ffi=={worker._APACHE_TVM_FFI_PACKAGE_VERSION}" in args
|
|
assert f"tilelang=={worker._TILELANG_PACKAGE_VERSION}" in args
|
|
assert run_mock.call_args.kwargs["timeout"] == worker._TILELANG_INSTALL_TIMEOUT_S
|
|
assert any("Installing TileLang" in s for s in statuses)
|
|
|
|
|
|
def test_tilelang_backend_reinstalls_when_tvm_ffi_is_broken(monkeypatch):
|
|
"""Repair path issues TWO pip calls:
|
|
|
|
1 (repair): --force-reinstall --no-deps apache-tvm-ffi -- downgrades only
|
|
the broken package; --no-deps stops the cascade through its deps to torch.
|
|
2 (install): plain apache-tvm-ffi + tilelang -- resolves missing transitive
|
|
deps without --force-reinstall, so it never replaces correct packages.
|
|
"""
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
monkeypatch.setattr(worker, "_installed_tvm_ffi_version", lambda: "0.1.11")
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
|
|
|
|
worker._ensure_tilelang_backend(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
assert run_mock.call_count == 2
|
|
repair_args, install_args = (call[0][0] for call in run_mock.call_args_list)
|
|
|
|
# Repair: --force-reinstall --no-deps, apache-tvm-ffi ONLY.
|
|
assert "--force-reinstall" in repair_args
|
|
assert "--no-deps" in repair_args, "Repair MUST use --no-deps to avoid replacing torch / CUDA"
|
|
assert "--only-binary=:all:" in repair_args
|
|
assert f"apache-tvm-ffi=={worker._APACHE_TVM_FFI_PACKAGE_VERSION}" in repair_args
|
|
assert all("tilelang" not in a for a in repair_args), "Repair MUST only touch apache-tvm-ffi"
|
|
|
|
# Install: regular dep-resolving install, no --force-reinstall.
|
|
assert "--force-reinstall" not in install_args
|
|
assert "--no-deps" not in install_args
|
|
assert "--only-binary=:all:" in install_args
|
|
assert f"apache-tvm-ffi=={worker._APACHE_TVM_FFI_PACKAGE_VERSION}" in install_args
|
|
assert f"tilelang=={worker._TILELANG_PACKAGE_VERSION}" in install_args
|
|
|
|
|
|
def test_tilelang_backend_skipped_below_python_3_10(monkeypatch):
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
# sys.version_info is a structseq, not constructible; substitute a
|
|
# plain tuple so the `< _FLA_MIN_PYTHON` comparison still works.
|
|
monkeypatch.setattr(worker.sys, "version_info", (3, 9, 0, "final", 0))
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
|
|
worker._ensure_tilelang_backend(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_tilelang_backend_skipped_on_windows(monkeypatch):
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.sys, "platform", "win32")
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
|
|
worker._ensure_tilelang_backend(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_tilelang_backend_swallows_install_timeout(monkeypatch):
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
monkeypatch.setattr(worker, "_installed_tvm_ffi_version", lambda: None)
|
|
_force_missing_tilelang_imports(monkeypatch)
|
|
|
|
def raise_timeout(*a, **kw):
|
|
raise subprocess.TimeoutExpired(cmd = "pip", timeout = 1)
|
|
|
|
monkeypatch.setattr(worker._sp, "run", raise_timeout)
|
|
statuses: list[str] = []
|
|
monkeypatch.setattr(worker, "_send_status", lambda queue, msg: statuses.append(msg))
|
|
|
|
# Must not raise.
|
|
worker._ensure_tilelang_backend(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
assert any("timed out" in s.lower() for s in statuses)
|
|
|
|
|
|
def test_tilelang_backend_skipped_for_ssm_models(monkeypatch):
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
|
|
# Nemotron-H / Falcon-H1 / Granite-H take the mamba_ssm path, not FLA's
|
|
# gated_delta_rule -> tilelang doesn't affect them.
|
|
for name in (
|
|
"tiiuae/Falcon-H1-0.5B-Instruct",
|
|
"nvidia/Nemotron-H-8B-Base",
|
|
"ibm-granite/granite-4.0-h-tiny",
|
|
"meta-llama/Llama-3.2-1B-Instruct",
|
|
):
|
|
worker._ensure_tilelang_backend(event_queue = [], model_name = name)
|
|
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_tilelang_backend_skipped_via_env(monkeypatch):
|
|
monkeypatch.setenv(worker._TILELANG_SKIP_ENV, "1")
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
|
|
worker._ensure_tilelang_backend(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_tilelang_backend_swallows_install_failure(monkeypatch):
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: None)
|
|
monkeypatch.setattr(worker, "_installed_tvm_ffi_version", lambda: None)
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 1, stdout = "boom"))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
_force_missing_tilelang_imports(monkeypatch)
|
|
statuses: list[str] = []
|
|
monkeypatch.setattr(worker, "_send_status", lambda queue, msg: statuses.append(msg))
|
|
|
|
# Should not raise even when pip exits non-zero.
|
|
worker._ensure_tilelang_backend(
|
|
event_queue = [],
|
|
model_name = "unsloth/Qwen3.5-2B",
|
|
)
|
|
|
|
run_mock.assert_called_once()
|
|
assert any("failed" in s.lower() for s in statuses)
|
|
|
|
|
|
# Runtime hook on is_flash_linear_attention_available /
|
|
# is_causal_conv1d_available -- the primary gate in normal operation. The
|
|
# substring tests above cover the SKIP_FAST_PATH_HOOKS=1 fallback.
|
|
|
|
|
|
class _FakeQueue(list):
|
|
"""List with `.put` so worker._send_status can send into it in tests."""
|
|
|
|
def put(self, item):
|
|
self.append(item)
|
|
|
|
|
|
def _make_fake_gate(initial_return: bool):
|
|
"""Callable mimicking transformers' lru_cache-decorated gates.
|
|
|
|
Tracks call count and exposes `cache_clear`. Flip `.next_return` to
|
|
mimic install-then-True behaviour.
|
|
"""
|
|
|
|
class Gate:
|
|
def __init__(self, initial: bool) -> None:
|
|
self.next_return = initial
|
|
self.call_count = 0
|
|
self.cache_clear_count = 0
|
|
|
|
def __call__(self) -> bool:
|
|
self.call_count += 1
|
|
return self.next_return
|
|
|
|
def cache_clear(self) -> None:
|
|
self.cache_clear_count += 1
|
|
|
|
return Gate(initial_return)
|
|
|
|
|
|
def _patch_iu_gates(monkeypatch, fla_gate, conv_gate):
|
|
"""Drop fake gates onto transformers.utils.import_utils for the test."""
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
monkeypatch.setattr(_iu, "is_flash_linear_attention_available", fla_gate)
|
|
monkeypatch.setattr(_iu, "is_causal_conv1d_available", conv_gate)
|
|
|
|
|
|
def test_hook_installs_when_gate_returns_false(monkeypatch):
|
|
_pin_fla_model_types(monkeypatch)
|
|
fla_gate = _make_fake_gate(initial_return = False)
|
|
conv_gate = _make_fake_gate(initial_return = False)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
def _fla_install_side_effect(eq):
|
|
fla_gate.next_return = True
|
|
return True
|
|
|
|
fla_install = mock.Mock(side_effect = _fla_install_side_effect)
|
|
tile_install = mock.Mock(side_effect = lambda eq: None)
|
|
|
|
def _conv_install_side_effect(**kw):
|
|
conv_gate.next_return = True
|
|
return True
|
|
|
|
conv_install = mock.Mock(side_effect = _conv_install_side_effect)
|
|
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", fla_install)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", tile_install)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", conv_install)
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
# Both gates wrapped; calling them should drive the install.
|
|
assert _iu.is_flash_linear_attention_available() is True
|
|
fla_install.assert_called_once()
|
|
tile_install.assert_called_once()
|
|
assert _iu.is_causal_conv1d_available() is True
|
|
conv_install.assert_called_once()
|
|
|
|
|
|
def test_hook_skips_install_when_gate_already_true(monkeypatch):
|
|
"""Both gates already True AND tilelang healthy -> zero install work.
|
|
(Tilelang repair on the already-True path is covered by
|
|
test_hook_runs_tilelang_repair_when_fla_already_true.)
|
|
"""
|
|
fla_gate = _make_fake_gate(initial_return = True)
|
|
conv_gate = _make_fake_gate(initial_return = True)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
fla_install = mock.Mock()
|
|
tile_install = mock.Mock()
|
|
conv_install = mock.Mock()
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", fla_install)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", tile_install)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", conv_install)
|
|
# Tilelang healthy -> post_available path is a no-op (otherwise it
|
|
# would call tile_install, correct but out of scope here).
|
|
monkeypatch.setattr(worker, "_tilelang_importable", lambda: True)
|
|
monkeypatch.setattr(worker, "_installed_tvm_ffi_version", lambda: "0.1.9")
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
assert _iu.is_flash_linear_attention_available() is True
|
|
assert _iu.is_causal_conv1d_available() is True
|
|
fla_install.assert_not_called()
|
|
tile_install.assert_not_called()
|
|
conv_install.assert_not_called()
|
|
|
|
|
|
def test_hook_idempotent_on_repeat_call(monkeypatch):
|
|
_pin_fla_model_types(monkeypatch)
|
|
fla_gate = _make_fake_gate(initial_return = False)
|
|
conv_gate = _make_fake_gate(initial_return = False)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
def _fla_install_side_effect(eq):
|
|
fla_gate.next_return = True
|
|
return True
|
|
|
|
fla_install = mock.Mock(side_effect = _fla_install_side_effect)
|
|
tile_install = mock.Mock()
|
|
|
|
def _conv_install_side_effect(**kw):
|
|
conv_gate.next_return = True
|
|
return True
|
|
|
|
conv_install = mock.Mock(side_effect = _conv_install_side_effect)
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", fla_install)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", tile_install)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", conv_install)
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
# First call: hook fires.
|
|
_iu.is_flash_linear_attention_available()
|
|
# Later calls: must not re-trigger the installer.
|
|
_iu.is_flash_linear_attention_available()
|
|
_iu.is_flash_linear_attention_available()
|
|
assert fla_install.call_count == 1
|
|
assert tile_install.call_count == 1
|
|
|
|
|
|
def test_hook_handles_install_failure_gracefully(monkeypatch):
|
|
fla_gate = _make_fake_gate(initial_return = False)
|
|
conv_gate = _make_fake_gate(initial_return = True) # bypass to focus on FLA
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
def raising_install(eq):
|
|
raise RuntimeError("pip failed to fetch wheel")
|
|
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", raising_install)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", lambda eq: None)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", lambda **kw: None)
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
# Must not raise; returns False so transformers uses the torch loop.
|
|
assert _iu.is_flash_linear_attention_available() is False
|
|
|
|
|
|
def test_hook_can_be_disabled_via_env(monkeypatch):
|
|
fla_gate = _make_fake_gate(initial_return = False)
|
|
conv_gate = _make_fake_gate(initial_return = False)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
fla_install = mock.Mock()
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", fla_install)
|
|
monkeypatch.setenv(worker._FAST_PATH_HOOKS_SKIP_ENV, "1")
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
# Hook not installed; gates remain the fakes.
|
|
assert _iu.is_flash_linear_attention_available is fla_gate
|
|
assert _iu.is_causal_conv1d_available is conv_gate
|
|
fla_install.assert_not_called()
|
|
|
|
|
|
def test_hook_clears_lru_cache_before_first_check(monkeypatch):
|
|
fla_gate = _make_fake_gate(initial_return = True)
|
|
conv_gate = _make_fake_gate(initial_return = True)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", lambda eq: None)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", lambda eq: None)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", lambda **kw: None)
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
_iu.is_flash_linear_attention_available()
|
|
# Wrapper called cache_clear at least once before delegating.
|
|
assert fla_gate.cache_clear_count >= 1
|
|
|
|
|
|
def test_hook_rewrites_previously_imported_module_bindings(monkeypatch):
|
|
"""Modeling files bind is_flash_linear_attention_available locally via
|
|
`from ... import is_X`. Reassigning the attribute on import_utils alone
|
|
misses those; the hook installer sweeps sys.modules and rebinds them.
|
|
"""
|
|
fla_gate = _make_fake_gate(initial_return = False)
|
|
conv_gate = _make_fake_gate(initial_return = True)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
# Fake modeling module that did `from ... import is_flash_linear_attention_available`.
|
|
fake_mod = sys.modules.setdefault(
|
|
"_test_fake_modeling_qwen35", type(sys)("_test_fake_modeling_qwen35")
|
|
)
|
|
fake_mod.is_flash_linear_attention_available = fla_gate
|
|
|
|
def fake_install(eq):
|
|
fla_gate.next_return = True
|
|
return True
|
|
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", fake_install)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", lambda eq: True)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", lambda **kw: True)
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
# The fake module's local binding is rewritten to the wrapper.
|
|
assert fake_mod.is_flash_linear_attention_available is not fla_gate
|
|
# Calling through the fake module's reference triggers install.
|
|
assert fake_mod.is_flash_linear_attention_available() is True
|
|
|
|
del sys.modules["_test_fake_modeling_qwen35"]
|
|
|
|
|
|
def test_hook_skips_when_import_utils_unavailable(monkeypatch):
|
|
"""If transformers.utils.import_utils can't be imported, the hook
|
|
installer must log and return cleanly rather than crash the worker."""
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(name, *a, **kw):
|
|
if name == "transformers.utils" or name == "transformers.utils.import_utils":
|
|
raise ImportError("transformers missing in worker venv")
|
|
return real_import(name, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
# Should not raise.
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
|
|
def test_substring_fallback_unchanged_when_hook_skipped(monkeypatch):
|
|
"""Hook disabled -> legacy gate falls back to auto-discovered types."""
|
|
install_mock = mock.Mock()
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", install_mock)
|
|
monkeypatch.setattr(worker, "_discover_fla_model_types", lambda: frozenset({"qwen3_5"}))
|
|
monkeypatch.setenv(worker._FAST_PATH_HOOKS_SKIP_ENV, "1")
|
|
|
|
worker._ensure_flash_linear_attention(event_queue = [], model_name = "unsloth/Qwen3.5-2B")
|
|
assert install_mock.call_count == 1
|
|
|
|
worker._ensure_flash_linear_attention(event_queue = [], model_name = "meta-llama/Llama-3.1-8B")
|
|
assert install_mock.call_count == 1
|
|
|
|
|
|
# Regression tests for the reviewer findings:
|
|
# 1. tilelang Qwen-guard on hook path (non-Qwen FLA models)
|
|
# 2. tilelang repair must not replace torch / CUDA stack
|
|
# 3. hook must trust installer's bool, not transformers metadata
|
|
# 4. causal-conv1d must stay eager for SSM models that bypass the gate
|
|
# 5. rebind sweep must not invoke lazy module __getattr__
|
|
# 6. tilelang skipped when FLA was skipped / failed
|
|
# 7. tilelang repair runs when FLA is already True
|
|
# 8. older FLA detected as stale and reinstalled
|
|
|
|
|
|
def test_hook_does_not_install_tilelang_for_model_outside_allowlist(monkeypatch):
|
|
"""A model not in the auto-discovered FLA allowlist calls
|
|
is_flash_linear_attention_available but must NOT get tilelang."""
|
|
fla_gate = _make_fake_gate(initial_return = False)
|
|
conv_gate = _make_fake_gate(initial_return = True)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
def _fla_install(eq):
|
|
fla_gate.next_return = True
|
|
return True
|
|
|
|
fla_install = mock.Mock(side_effect = _fla_install)
|
|
tile_install = mock.Mock(return_value = True)
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", fla_install)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", tile_install)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", mock.Mock(return_value = True))
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
# Hermetize the auto-discovered set so the test stays valid as new
|
|
# transformers releases add FLA-using model_types (eg olmo_hybrid in
|
|
# 5.4.0). Test semantic: "outside-allowlist -> no tilelang".
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"_discover_fla_model_types",
|
|
lambda: frozenset({"qwen3_5", "qwen3_5_moe", "qwen3_next"}),
|
|
)
|
|
|
|
worker._install_fast_path_hooks(
|
|
event_queue = _FakeQueue(),
|
|
model_name = "fake-org/Fictional-FLA-Only-Model-7B",
|
|
)
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
assert _iu.is_flash_linear_attention_available() is True
|
|
fla_install.assert_called_once()
|
|
tile_install.assert_not_called()
|
|
|
|
|
|
def test_hook_does_install_tilelang_for_qwen35(monkeypatch):
|
|
"""Positive control for finding #1: Qwen3.5 still gets tilelang."""
|
|
_pin_fla_model_types(monkeypatch)
|
|
fla_gate = _make_fake_gate(initial_return = False)
|
|
conv_gate = _make_fake_gate(initial_return = True)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
def _fla_install(eq):
|
|
fla_gate.next_return = True
|
|
return True
|
|
|
|
fla_install = mock.Mock(side_effect = _fla_install)
|
|
tile_install = mock.Mock(return_value = True)
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", fla_install)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", tile_install)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", mock.Mock(return_value = True))
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
_iu.is_flash_linear_attention_available()
|
|
fla_install.assert_called_once()
|
|
tile_install.assert_called_once()
|
|
|
|
|
|
def test_tilelang_repair_does_not_touch_torch_cuda_stack(monkeypatch):
|
|
"""Finding #2: the broken-tvm-ffi repair must use --no-deps on the
|
|
forced step so --force-reinstall doesn't cascade through
|
|
apache-tvm-ffi's dep graph and pull a different torch wheel.
|
|
"""
|
|
_pin_fla_model_types(monkeypatch)
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
monkeypatch.setattr(worker, "_installed_tvm_ffi_version", lambda: "0.1.10")
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
|
|
|
|
worker._ensure_tilelang_backend(event_queue = [], model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
assert run_mock.call_count == 2
|
|
repair_args = run_mock.call_args_list[0][0][0]
|
|
# Forced step MUST be --no-deps so torch / CUDA stack is untouched.
|
|
assert "--force-reinstall" in repair_args and "--no-deps" in repair_args
|
|
# Touches ONLY apache-tvm-ffi, not tilelang / torch.
|
|
assert all("tilelang" not in a for a in repair_args)
|
|
assert all("torch" not in a for a in repair_args)
|
|
|
|
|
|
def test_hook_trusts_installer_bool_not_metadata(monkeypatch):
|
|
"""Finding #3: if pip exits 0 but deep imports fail, the installer returns
|
|
False; the hook must propagate that False even if the metadata-only gate
|
|
returns True after pip succeeds, so transformers takes the torch fallback.
|
|
"""
|
|
# Gate flips True after install (simulating "metadata sees fla").
|
|
fla_gate = _make_fake_gate(initial_return = False)
|
|
conv_gate = _make_fake_gate(initial_return = True)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
# Installer "succeeds" at pip and flips the gate to True (metadata
|
|
# sees fla post-install), but returns False (deep import broken).
|
|
def _bad_install(eq):
|
|
fla_gate.next_return = True # metadata says yes after pip
|
|
return False # but deep import is broken
|
|
|
|
fake_fla_install = mock.Mock(side_effect = _bad_install)
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", fake_fla_install)
|
|
monkeypatch.setattr(
|
|
worker, "_ensure_tilelang_backend_unconditional", mock.Mock(return_value = True)
|
|
)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", mock.Mock(return_value = True))
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
# Hook MUST return False (installer's verdict), not True (metadata lies).
|
|
assert _iu.is_flash_linear_attention_available() is False
|
|
fake_fla_install.assert_called_once()
|
|
|
|
|
|
def test_rebind_does_not_trigger_module_getattr(monkeypatch):
|
|
"""Finding #5: the rebind sweep must use __dict__, not getattr(), to
|
|
avoid invoking transformers' lazy module __getattr__ which spits out
|
|
hundreds of "Accessing X from .models..." warnings.
|
|
"""
|
|
original = object()
|
|
replacement = object()
|
|
|
|
class _GetattrTripwire(type(sys)):
|
|
getattr_called = False
|
|
|
|
def __getattr__(self, name):
|
|
type(self).getattr_called = True
|
|
raise AttributeError(name)
|
|
|
|
lazy = _GetattrTripwire("_lazy_test_module")
|
|
sys.modules["_lazy_test_module"] = lazy
|
|
try:
|
|
# No `is_flash_linear_attention_available` in __dict__, so the
|
|
# sweep must NOT trip the tripwire.
|
|
worker._rebind_in_already_imported_modules(
|
|
attr_name = "is_flash_linear_attention_available",
|
|
old_obj = original,
|
|
new_obj = replacement,
|
|
)
|
|
assert (
|
|
not _GetattrTripwire.getattr_called
|
|
), "Rebind sweep invoked __getattr__ — should use __dict__ probe"
|
|
finally:
|
|
sys.modules.pop("_lazy_test_module", None)
|
|
|
|
|
|
def test_hook_skips_tilelang_when_fla_install_is_skipped(monkeypatch):
|
|
"""Finding #6: env-skipped FLA returns False from
|
|
_ensure_flash_linear_attention_unconditional; tilelang must NOT
|
|
install then.
|
|
"""
|
|
fla_gate = _make_fake_gate(initial_return = False)
|
|
conv_gate = _make_fake_gate(initial_return = True)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
monkeypatch.setenv(worker._FLA_SKIP_ENV, "1")
|
|
tile_install = mock.Mock(return_value = True)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", tile_install)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", mock.Mock(return_value = True))
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
# FLA gate stays False (env-skipped, install never ran).
|
|
assert _iu.is_flash_linear_attention_available() is False
|
|
tile_install.assert_not_called()
|
|
|
|
|
|
def test_hook_runs_tilelang_repair_when_fla_already_true(monkeypatch):
|
|
"""Finding #7: when FLA is already importable (gate True at first
|
|
probe) but tilelang is missing or apache-tvm-ffi is on the broken
|
|
list, the post-available action must still run tilelang.
|
|
"""
|
|
_pin_fla_model_types(monkeypatch)
|
|
fla_gate = _make_fake_gate(initial_return = True)
|
|
conv_gate = _make_fake_gate(initial_return = True)
|
|
_patch_iu_gates(monkeypatch, fla_gate, conv_gate)
|
|
|
|
fla_install = mock.Mock(return_value = True)
|
|
tile_install = mock.Mock(return_value = True)
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", fla_install)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", tile_install)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", mock.Mock(return_value = True))
|
|
# tilelang missing AND tvm-ffi on broken list — both trigger repair.
|
|
monkeypatch.setattr(worker, "_tilelang_importable", lambda: False)
|
|
monkeypatch.setattr(worker, "_installed_tvm_ffi_version", lambda: "0.1.11")
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
from transformers.utils import import_utils as _iu
|
|
|
|
_iu.is_flash_linear_attention_available()
|
|
# FLA install NOT needed; tilelang repair still triggered.
|
|
fla_install.assert_not_called()
|
|
tile_install.assert_called_once()
|
|
|
|
|
|
def test_fla_installer_force_reinstalls_when_older_version_present(monkeypatch):
|
|
"""Finding #8: an older `flash-linear-attention` that is importable
|
|
but below the pin must force a reinstall (not no-op).
|
|
"""
|
|
monkeypatch.delenv(worker._FLA_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: "/usr/bin/uv")
|
|
monkeypatch.setattr(worker, "_installed_torch_version_tuple", lambda: (2, 9))
|
|
# Importable but stale (current()=False though importable()=True).
|
|
monkeypatch.setattr(worker, "_flash_linear_attention_importable", lambda: True)
|
|
monkeypatch.setattr(worker, "_flash_linear_attention_current", lambda **kw: False)
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
|
|
|
|
worker._ensure_flash_linear_attention_unconditional(event_queue = [])
|
|
|
|
run_mock.assert_called_once()
|
|
args = run_mock.call_args[0][0]
|
|
assert (
|
|
"--force-reinstall" in args
|
|
), "Stale FLA must trigger --force-reinstall, otherwise pip is a no-op"
|
|
# --no-deps still applies so torch stays untouched.
|
|
assert "--no-deps" in args
|
|
|
|
|
|
def test_run_training_process_eagerly_installs_causal_conv1d_in_normal_mode():
|
|
"""Finding #4: SSM modeling files use lazy_load_kernel and never call
|
|
is_causal_conv1d_available(), so the hook won't fire; the orchestrator must
|
|
always run the eager installer regardless of hook mode. Reads the worker
|
|
source and asserts the eager install is OUTSIDE the if/else hook branch.
|
|
"""
|
|
import inspect
|
|
|
|
src = inspect.getsource(worker.run_training_process)
|
|
# Orchestration block.
|
|
assert "_ensure_causal_conv1d_fast_path(event_queue, model_name)" in src
|
|
assert "_install_fast_path_hooks(event_queue, model_name)" in src
|
|
# Eager causal_conv1d call must come BEFORE the hook-mode if/else, not
|
|
# nested inside the `if _FAST_PATH_HOOKS_SKIP_ENV` branch.
|
|
eager_pos = src.find("_ensure_causal_conv1d_fast_path(event_queue, model_name)")
|
|
skip_check_pos = src.find('os.getenv(_FAST_PATH_HOOKS_SKIP_ENV) == "1"')
|
|
assert eager_pos < skip_check_pos, (
|
|
"_ensure_causal_conv1d_fast_path must be called BEFORE the hook-mode "
|
|
"branch, so SSM models that bypass is_causal_conv1d_available() still "
|
|
"get the eager install"
|
|
)
|
|
|
|
|
|
# HIP / ROCm regression coverage (Strix Halo report).
|
|
# tilelang 0.1.8 has no HIP GEMM backend; FLA's TileLang dispatch crashes
|
|
# mid-backward on AMD ("Unsupported target for gemm: hip"). Fix: skip install on
|
|
# HIP torch AND setdefault FLA_TILELANG=0 so an existing tilelang isn't used.
|
|
|
|
|
|
def test_tilelang_platform_unsupported_on_hip_torch(monkeypatch):
|
|
"""Strix Halo / MI300 with ROCm torch: linux + x86_64 looks identical
|
|
to a CUDA box at the OS level, so the platform check must consult
|
|
torch.version.hip explicitly.
|
|
"""
|
|
monkeypatch.setattr(worker, "_torch_has_hip", lambda: True)
|
|
assert worker._tilelang_platform_supported() is False
|
|
|
|
|
|
def test_tilelang_install_skipped_on_hip_torch(monkeypatch):
|
|
"""End-to-end: the unconditional installer must not call pip on HIP torch."""
|
|
monkeypatch.delenv(worker._TILELANG_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker, "_torch_has_hip", lambda: True)
|
|
run_mock = mock.Mock(return_value = mock.Mock(returncode = 0, stdout = ""))
|
|
monkeypatch.setattr(worker._sp, "run", run_mock)
|
|
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
|
|
|
|
result = worker._ensure_tilelang_backend_unconditional(event_queue = [])
|
|
|
|
assert result is False
|
|
run_mock.assert_not_called()
|
|
|
|
|
|
def test_install_fast_path_hooks_sets_fla_tilelang_zero_on_hip(monkeypatch):
|
|
"""On HIP torch, the hook installer must setdefault FLA_TILELANG=0
|
|
(respecting user override) so a PRE-EXISTING tilelang install isn't
|
|
used by FLA's dispatcher.
|
|
"""
|
|
import os as _os
|
|
|
|
monkeypatch.delenv("FLA_TILELANG", raising = False)
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker, "_torch_has_hip", lambda: True)
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", lambda eq: True)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", lambda eq: True)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", lambda **kw: True)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
assert _os.environ.get("FLA_TILELANG") == "0"
|
|
|
|
|
|
def test_install_fast_path_hooks_respects_user_fla_tilelang_override(monkeypatch):
|
|
"""If the user set FLA_TILELANG (even on HIP), don't overwrite — they
|
|
may have a HIP-aware tilelang fork.
|
|
"""
|
|
import os as _os
|
|
|
|
monkeypatch.setenv("FLA_TILELANG", "1")
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker, "_torch_has_hip", lambda: True)
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", lambda eq: True)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", lambda eq: True)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", lambda **kw: True)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
assert _os.environ["FLA_TILELANG"] == "1"
|
|
|
|
|
|
def test_install_fast_path_hooks_does_not_set_fla_tilelang_on_cuda(monkeypatch):
|
|
"""CUDA path must NOT set FLA_TILELANG (tilelang is wanted there)."""
|
|
import os as _os
|
|
|
|
monkeypatch.delenv("FLA_TILELANG", raising = False)
|
|
monkeypatch.delenv(worker._FAST_PATH_HOOKS_SKIP_ENV, raising = False)
|
|
monkeypatch.setattr(worker, "_torch_has_hip", lambda: False)
|
|
monkeypatch.setattr(worker, "_ensure_flash_linear_attention_unconditional", lambda eq: True)
|
|
monkeypatch.setattr(worker, "_ensure_tilelang_backend_unconditional", lambda eq: True)
|
|
monkeypatch.setattr(worker, "_install_package_wheel_first", lambda **kw: True)
|
|
|
|
worker._install_fast_path_hooks(event_queue = _FakeQueue(), model_name = "unsloth/Qwen3.5-2B")
|
|
|
|
assert _os.environ.get("FLA_TILELANG") is None
|
|
|
|
|
|
# ───────────────────────────────────────────────────────────────────
|
|
# Auto-discovery of FLA model_types from the installed transformers
|
|
# ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_fake_transformers_tree(tmp_path, fla_types: list[str], non_fla_types: list[str]):
|
|
"""Lay out tmp dir as `transformers/models/{type}/modeling_{type}.py`."""
|
|
pkg = tmp_path / "transformers"
|
|
models = pkg / "models"
|
|
models.mkdir(parents = True)
|
|
(pkg / "__init__.py").write_text("")
|
|
for t in fla_types:
|
|
d = models / t
|
|
d.mkdir()
|
|
(d / f"modeling_{t}.py").write_text(
|
|
"from ...utils.import_utils import is_flash_linear_attention_available\n"
|
|
"if is_flash_linear_attention_available():\n"
|
|
" from fla.modules import FusedRMSNormGated\n"
|
|
" from fla.ops.gated_delta_rule import chunk_gated_delta_rule\n"
|
|
)
|
|
for t in non_fla_types:
|
|
d = models / t
|
|
d.mkdir()
|
|
(d / f"modeling_{t}.py").write_text("class Foo: pass\n")
|
|
return pkg
|
|
|
|
|
|
def _reset_fla_cache(monkeypatch):
|
|
monkeypatch.setattr(worker, "_TRANSFORMERS_FLA_MODEL_TYPES_CACHE", None)
|
|
|
|
|
|
def test_discover_fla_model_types_returns_only_fla_users(tmp_path, monkeypatch):
|
|
pkg = _make_fake_transformers_tree(
|
|
tmp_path,
|
|
fla_types = ["qwen3_5", "qwen3_5_moe", "qwen3_next"],
|
|
non_fla_types = ["llama", "gpt2", "mistral"],
|
|
)
|
|
fake = mock.MagicMock(__file__ = str(pkg / "__init__.py"))
|
|
monkeypatch.setitem(sys.modules, "transformers", fake)
|
|
_reset_fla_cache(monkeypatch)
|
|
|
|
result = worker._discover_fla_model_types()
|
|
assert result == frozenset({"qwen3_5", "qwen3_5_moe", "qwen3_next"})
|
|
assert "llama" not in result
|
|
assert "gpt2" not in result
|
|
|
|
|
|
def test_discover_fla_model_types_caches_across_calls(tmp_path, monkeypatch):
|
|
pkg = _make_fake_transformers_tree(tmp_path, fla_types = ["qwen3_5"], non_fla_types = [])
|
|
fake = mock.MagicMock(__file__ = str(pkg / "__init__.py"))
|
|
monkeypatch.setitem(sys.modules, "transformers", fake)
|
|
_reset_fla_cache(monkeypatch)
|
|
|
|
from pathlib import Path as _Path
|
|
|
|
read_calls = [0]
|
|
real_read = _Path.read_text
|
|
|
|
def counting_read(self, *a, **kw):
|
|
read_calls[0] += 1
|
|
return real_read(self, *a, **kw)
|
|
|
|
monkeypatch.setattr(_Path, "read_text", counting_read)
|
|
|
|
first = worker._discover_fla_model_types()
|
|
after_first = read_calls[0]
|
|
second = worker._discover_fla_model_types()
|
|
|
|
assert first == second
|
|
assert read_calls[0] == after_first # cache hit: no extra reads
|
|
|
|
|
|
def test_discover_fla_model_types_handles_missing_transformers(monkeypatch):
|
|
_reset_fla_cache(monkeypatch)
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def fake_import(
|
|
name,
|
|
globals = None,
|
|
locals = None,
|
|
fromlist = (),
|
|
level = 0,
|
|
):
|
|
if name == "transformers":
|
|
raise ImportError("transformers not installed")
|
|
return real_import(name, globals, locals, fromlist, level)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
|
result = worker._discover_fla_model_types()
|
|
assert result == frozenset()
|
|
|
|
|
|
def test_discover_fla_model_types_handles_unreadable_file(tmp_path, monkeypatch):
|
|
pkg = _make_fake_transformers_tree(tmp_path, fla_types = ["qwen3_5"], non_fla_types = [])
|
|
fake = mock.MagicMock(__file__ = str(pkg / "__init__.py"))
|
|
monkeypatch.setitem(sys.modules, "transformers", fake)
|
|
_reset_fla_cache(monkeypatch)
|
|
|
|
from pathlib import Path as _Path
|
|
|
|
real_read = _Path.read_text
|
|
|
|
def boom_read(self, *a, **kw):
|
|
if "modeling_qwen3_5.py" in str(self):
|
|
raise OSError("permission denied")
|
|
return real_read(self, *a, **kw)
|
|
|
|
monkeypatch.setattr(_Path, "read_text", boom_read)
|
|
result = worker._discover_fla_model_types()
|
|
assert result == frozenset() # unreadable file doesn't contribute
|
|
|
|
|
|
def test_model_wants_tilelang_handles_real_repo_names(monkeypatch):
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"_discover_fla_model_types",
|
|
lambda: frozenset({"qwen3_5", "qwen3_5_moe", "qwen3_next"}),
|
|
)
|
|
cases = [
|
|
("unsloth/Qwen3.5-2B", True),
|
|
("Qwen/Qwen3.5-MoE-A3B", True),
|
|
("mlx-community/qwen3-next-80b", True),
|
|
("unsloth/qwen3_5_moe_a3b_lora", True),
|
|
("meta-llama/Llama-3.1-8B", False),
|
|
("nvidia/Nemotron-H-4B", False),
|
|
("mistralai/Mistral-7B-v0.3", False),
|
|
("", False),
|
|
]
|
|
for name, expected in cases:
|
|
assert worker._model_wants_tilelang(name) is expected, name
|
|
|
|
|
|
def test_model_wants_tilelang_empty_when_transformers_has_no_fla(monkeypatch):
|
|
monkeypatch.setattr(worker, "_discover_fla_model_types", lambda: frozenset())
|
|
assert worker._model_wants_tilelang("unsloth/Qwen3.5-2B") is False
|
|
assert worker._model_wants_tilelang("meta-llama/Llama-3.1-8B") is False
|
|
|
|
|
|
def test_model_wants_tilelang_normalizes_separators(monkeypatch):
|
|
monkeypatch.setattr(worker, "_discover_fla_model_types", lambda: frozenset({"qwen3_next"}))
|
|
for variant in (
|
|
"qwen3-next",
|
|
"Qwen3.Next",
|
|
"Qwen/Qwen3 Next",
|
|
"anyone/qwen3_next",
|
|
"qwen3.next-80b",
|
|
):
|
|
assert worker._model_wants_tilelang(variant) is True, variant
|
|
|
|
|
|
# HIP source-build gcc-install-dir coverage (Strix Halo).
|
|
# Ubuntu 24.04 ships gcc-14's runtime dir without /usr/include/c++/14, so ROCm
|
|
# clang-20 picks it and fails ('cstdlib' not found) building causal-conv1d.
|
|
# _hipcc_gcc_install_dir() finds a gcc dir with both halves; the HIP branch of
|
|
# _install_package_wheel_first passes it via HIPCC_COMPILE_FLAGS_APPEND.
|
|
# Parallels bbf004c's setup.sh fix for the llama.cpp HIP build (PR #5301).
|
|
|
|
|
|
def _isdir_for_layout(*existing: str):
|
|
"""os.path.isdir replacement treating only the given absolute paths as
|
|
directories, to simulate which gcc runtime / C++ header dirs exist."""
|
|
valid = set(existing)
|
|
|
|
def fake_isdir(path: str) -> bool:
|
|
return path in valid
|
|
|
|
return fake_isdir
|
|
|
|
|
|
def test_hipcc_gcc_install_dir_picks_highest_with_headers(monkeypatch):
|
|
"""gcc-14 has runtime but no /usr/include/c++/14; loop falls through
|
|
to gcc-13 which has both. The exact Ubuntu 24.04 layout."""
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
import platform as _platform
|
|
|
|
monkeypatch.setattr(_platform, "machine", lambda: "x86_64")
|
|
monkeypatch.setattr(
|
|
worker.os.path,
|
|
"isdir",
|
|
_isdir_for_layout(
|
|
"/usr/lib/gcc/x86_64-linux-gnu/14/include", # runtime present
|
|
# but no /usr/include/c++/14 — typical Ubuntu 24.04 default
|
|
"/usr/lib/gcc/x86_64-linux-gnu/13/include",
|
|
"/usr/include/c++/13", # libstdc++-13-dev installed
|
|
),
|
|
)
|
|
assert worker._hipcc_gcc_install_dir() == "/usr/lib/gcc/x86_64-linux-gnu/13"
|
|
|
|
|
|
def test_hipcc_gcc_install_dir_picks_14_when_headers_exist(monkeypatch):
|
|
"""If the user has libstdc++-14-dev installed, prefer gcc-14."""
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
import platform as _platform
|
|
|
|
monkeypatch.setattr(_platform, "machine", lambda: "x86_64")
|
|
monkeypatch.setattr(
|
|
worker.os.path,
|
|
"isdir",
|
|
_isdir_for_layout(
|
|
"/usr/lib/gcc/x86_64-linux-gnu/14/include",
|
|
"/usr/include/c++/14",
|
|
),
|
|
)
|
|
assert worker._hipcc_gcc_install_dir() == "/usr/lib/gcc/x86_64-linux-gnu/14"
|
|
|
|
|
|
def test_hipcc_gcc_install_dir_returns_none_when_no_match(monkeypatch):
|
|
"""No gcc dir has both halves → return None and skip env injection
|
|
rather than guessing wrong and causing a confusing build failure."""
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
import platform as _platform
|
|
|
|
monkeypatch.setattr(_platform, "machine", lambda: "x86_64")
|
|
monkeypatch.setattr(worker.os.path, "isdir", lambda path: False)
|
|
assert worker._hipcc_gcc_install_dir() is None
|
|
|
|
|
|
def test_hipcc_gcc_install_dir_returns_none_on_non_linux(monkeypatch):
|
|
"""Don't probe gcc layout on macOS / Windows — early-return."""
|
|
monkeypatch.setattr(sys, "platform", "darwin")
|
|
|
|
def _isdir_should_not_be_called(_path):
|
|
raise AssertionError("isdir should not be called on non-Linux")
|
|
|
|
monkeypatch.setattr(worker.os.path, "isdir", _isdir_should_not_be_called)
|
|
assert worker._hipcc_gcc_install_dir() is None
|
|
|
|
|
|
def test_hipcc_gcc_install_dir_returns_none_on_non_x86_64(monkeypatch):
|
|
"""ROCm clang-20 on aarch64 has a different libstdc++ layout."""
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
import platform as _platform
|
|
|
|
monkeypatch.setattr(_platform, "machine", lambda: "aarch64")
|
|
assert worker._hipcc_gcc_install_dir() is None
|
|
|
|
|
|
def _make_hip_install_env(monkeypatch, *, gcc_dir: str | None):
|
|
"""Scaffolding for end-to-end tests of the HIP source-build branch of
|
|
_install_package_wheel_first: package not installed, no prebuilt
|
|
wheel, hipcc on PATH, fake env reports HIP torch."""
|
|
monkeypatch.setattr(builtins, "__import__", _missing_module_import("causal_conv1d"))
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"probe_torch_wheel_env",
|
|
lambda timeout = 30: {
|
|
"hip_version": "7.13.26176",
|
|
"python_tag": "cp312",
|
|
"torch_mm": "2.11",
|
|
"cxx11abi": "TRUE",
|
|
"platform_tag": "linux_x86_64",
|
|
},
|
|
)
|
|
monkeypatch.setattr(worker, "direct_wheel_url", lambda **kw: None)
|
|
monkeypatch.setattr(
|
|
worker.shutil,
|
|
"which",
|
|
lambda name: "/opt/rocm/bin/hipcc" if name == "hipcc" else None,
|
|
)
|
|
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
|
|
monkeypatch.setattr(worker, "_hipcc_gcc_install_dir", lambda: gcc_dir)
|
|
|
|
|
|
def test_install_injects_gcc_install_dir_on_hip_source_build(monkeypatch):
|
|
"""HIP source-build with no user-set HIPCC_COMPILE_FLAGS_APPEND →
|
|
subprocess env carries --gcc-install-dir=<detected path>."""
|
|
monkeypatch.delenv("HIPCC_COMPILE_FLAGS_APPEND", raising = False)
|
|
_make_hip_install_env(monkeypatch, gcc_dir = "/usr/lib/gcc/x86_64-linux-gnu/13")
|
|
|
|
captured: dict[str, str] = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.update(kwargs.get("env") or {})
|
|
return subprocess.CompletedProcess(cmd, 0, "")
|
|
|
|
monkeypatch.setattr(worker._sp, "run", fake_run)
|
|
|
|
worker._install_package_wheel_first(
|
|
event_queue = [],
|
|
import_name = "causal_conv1d",
|
|
display_name = "causal-conv1d",
|
|
pypi_name = "causal-conv1d",
|
|
pypi_version = "1.6.2.post1",
|
|
filename_prefix = "causal_conv1d",
|
|
release_tag = "v1.6.2.post1",
|
|
release_base_url = "https://example.com",
|
|
)
|
|
|
|
assert (
|
|
captured.get("HIPCC_COMPILE_FLAGS_APPEND")
|
|
== "--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/13"
|
|
)
|
|
|
|
|
|
def test_install_appends_to_existing_hipcc_compile_flags(monkeypatch):
|
|
"""User has HIPCC_COMPILE_FLAGS_APPEND='-O3 -DFOO' → final value keeps
|
|
the user's flags AND appends --gcc-install-dir."""
|
|
monkeypatch.setenv("HIPCC_COMPILE_FLAGS_APPEND", "-O3 -DFOO")
|
|
_make_hip_install_env(monkeypatch, gcc_dir = "/usr/lib/gcc/x86_64-linux-gnu/13")
|
|
|
|
captured: dict[str, str] = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.update(kwargs.get("env") or {})
|
|
return subprocess.CompletedProcess(cmd, 0, "")
|
|
|
|
monkeypatch.setattr(worker._sp, "run", fake_run)
|
|
|
|
worker._install_package_wheel_first(
|
|
event_queue = [],
|
|
import_name = "causal_conv1d",
|
|
display_name = "causal-conv1d",
|
|
pypi_name = "causal-conv1d",
|
|
pypi_version = "1.6.2.post1",
|
|
filename_prefix = "causal_conv1d",
|
|
release_tag = "v1.6.2.post1",
|
|
release_base_url = "https://example.com",
|
|
)
|
|
|
|
assert captured.get("HIPCC_COMPILE_FLAGS_APPEND") == (
|
|
"-O3 -DFOO --gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/13"
|
|
)
|
|
|
|
|
|
def test_install_respects_user_gcc_install_dir(monkeypatch):
|
|
"""User explicitly set --gcc-install-dir=… already → don't touch it.
|
|
Avoids two competing --gcc-install-dir flags on the clang command line."""
|
|
monkeypatch.setenv(
|
|
"HIPCC_COMPILE_FLAGS_APPEND",
|
|
"--gcc-install-dir=/opt/custom/gcc-13",
|
|
)
|
|
_make_hip_install_env(monkeypatch, gcc_dir = "/usr/lib/gcc/x86_64-linux-gnu/13")
|
|
|
|
captured: dict[str, str] | None = {"_called": "no"}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
env = kwargs.get("env")
|
|
if env is not None:
|
|
captured.clear()
|
|
captured.update(env)
|
|
else:
|
|
captured["_called"] = "yes_no_env"
|
|
return subprocess.CompletedProcess(cmd, 0, "")
|
|
|
|
monkeypatch.setattr(worker._sp, "run", fake_run)
|
|
|
|
worker._install_package_wheel_first(
|
|
event_queue = [],
|
|
import_name = "causal_conv1d",
|
|
display_name = "causal-conv1d",
|
|
pypi_name = "causal-conv1d",
|
|
pypi_version = "1.6.2.post1",
|
|
filename_prefix = "causal_conv1d",
|
|
release_tag = "v1.6.2.post1",
|
|
release_base_url = "https://example.com",
|
|
)
|
|
|
|
# subprocess.run invoked without env override (user already set
|
|
# HIPCC_COMPILE_FLAGS_APPEND with --gcc-install-dir, so we left the
|
|
# env alone — the existing value is inherited).
|
|
assert captured == {"_called": "yes_no_env"}
|
|
|
|
|
|
def test_install_does_not_inject_env_on_cuda(monkeypatch):
|
|
"""CUDA path (no hip_version in env) → no env override at all."""
|
|
monkeypatch.delenv("HIPCC_COMPILE_FLAGS_APPEND", raising = False)
|
|
monkeypatch.setattr(builtins, "__import__", _missing_module_import("causal_conv1d"))
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"probe_torch_wheel_env",
|
|
lambda timeout = 30: {
|
|
"python_tag": "cp312",
|
|
"torch_mm": "2.11",
|
|
"cuda_major": "12",
|
|
"cxx11abi": "TRUE",
|
|
"platform_tag": "linux_x86_64",
|
|
},
|
|
)
|
|
monkeypatch.setattr(worker, "direct_wheel_url", lambda **kw: None)
|
|
monkeypatch.setattr(worker.shutil, "which", lambda name: None)
|
|
monkeypatch.setattr(worker, "_send_status", lambda *a, **k: None)
|
|
# _hipcc_gcc_install_dir must not be called on CUDA.
|
|
monkeypatch.setattr(
|
|
worker,
|
|
"_hipcc_gcc_install_dir",
|
|
lambda: (_ for _ in ()).throw(AssertionError("must not run on CUDA")),
|
|
)
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured["env_in_kwargs"] = "env" in kwargs
|
|
return subprocess.CompletedProcess(cmd, 0, "")
|
|
|
|
monkeypatch.setattr(worker._sp, "run", fake_run)
|
|
|
|
worker._install_package_wheel_first(
|
|
event_queue = [],
|
|
import_name = "causal_conv1d",
|
|
display_name = "causal-conv1d",
|
|
pypi_name = "causal-conv1d",
|
|
pypi_version = "1.6.2.post1",
|
|
filename_prefix = "causal_conv1d",
|
|
release_tag = "v1.6.2.post1",
|
|
release_base_url = "https://example.com",
|
|
)
|
|
|
|
# CUDA branch never sets the env, never invokes the gcc helper.
|
|
assert captured.get("env_in_kwargs") is False
|