fix(gpu): run on Blackwell, and refuse a GPU this PyTorch build cannot use - #120
Merged
Conversation
… tag `latest-cuda` was pinned to pytorch/pytorch:2.6.0-cuda12.6-cudnn9-runtime, whose torch build ships cubins for sm_50 through sm_90. RTX 50-series cards are sm_120, so torch.cuda.is_available() reported True, a GPU VRAM profile was committed, and scoring then died on the first tensor with "no kernel image is available for execution on the device". The CUDA variant sets STRIP_TORCH=1, so torch comes entirely from the base image and no pip install on that path could have corrected it. `latest-cuda` now builds from 2.11.0-cuda12.8, which ships sm_75 through sm_120. That base drops sm_50/sm_60/sm_70, so Maxwell, Pascal and Volta cards would silently have lost their GPU; they get their own tag, `latest-cuda-legacy`, built from the previous base. The four GPU compose overlays still pull `latest-cuda`, so current hardware needs no change. Two lock files are now required rather than one. torch 2.6.0 requires sympy==1.13.1 exactly while torch 2.11.0 requires >=1.13.3, and a single shared pin made pip settle the conflict by discarding the base image's torch for a PyPI build. That is not hypothetical: it is what the published CPU image already shipped -- torch 2.6.0+cu124 with 13 nvidia-* wheels and 8.81 GB on disk, in the image whose whole purpose is carrying no CUDA. It now builds at 3.34 GB with torch 2.13.0+cpu and no CUDA wheels at all. The new base is Ubuntu 24.04, which is PEP 668 externally-managed and already owns UID 1000. pip therefore needs --break-system-packages, and the facet user needs an explicit --uid 1000: without it useradd landed on 1001, which made an existing install's database open read-only and left the operator's own bind-mounted data directory owned by a UID they could no longer delete. A build-time assertion now fails the build when BASE_IMAGE and REQUIREMENTS_LOCK disagree, and the publish workflow loads each image and smoke-tests its runtime UID, torch version, CUDA version and compiled arch flags before pushing, so none of the above can ship unnoticed again. Closes #119
torch.cuda.is_available() answers whether a driver and a device exist. It says nothing about whether the installed build carries cubins for that device's architecture, and nothing in the tree ever asked: get_arch_list() had zero occurrences, and no code allocated so much as a test tensor before committing a VRAM profile. That gap is why an RTX 50-series card sailed through detection, was assigned the 16gb profile, and only failed once scoring reached its first real tensor -- and why `--doctor` printed a fully green GPU section for a card that could not run a single kernel. Facet now compares the device's compute capability against the build's architecture list and launches one throwaway kernel before committing a profile. CUDA's own compatibility rule is honoured, so an sm_60 cubin still covers an sm_61 Pascal card and a compute_XY PTX entry covers anything above it. A card the build cannot run falls back to CPU and says so, naming the image tag or wheel index to switch to instead of dying later. The rule is deliberately an allow-list: only a definite architecture mismatch, or a failure carrying CUDA's own no-kernel-image or invalid-device-function signature, marks a GPU unusable. Anything else -- an out-of-memory, a device held in exclusive mode by another process, an unfamiliar CUDA error -- fails open and surfaces where it always did. A deny-list of the failures we happen to know about would demote a perfectly good GPU to a silent multi-hour CPU scan the first time an unrecognised error appeared, which is the same class of bug one release later. For the same reason an unknown is never treated as evidence: a torch exposing no get_arch_list, no capability or no usable allocator is reported usable. Disabling a GPU that works is the worse failure. The profile message now distinguishes a card that is absent from one that is present but unusable, so a scan and `--doctor` no longer contradict each other about why the run fell back to CPU.
The wheel-index ladder stepped straight from ">= 12.8" to ">= 12.4", so a driver reporting CUDA 12.6 or 12.7 -- the most common installed base -- fell into the cu124 branch and installed PyTorch built against a CUDA runtime up to three point releases behind the driver. Every other rung of the ladder matches its driver range; this one silently did not. A cu126 branch now sits between them, keeping the same onnxruntime-gpu constraint as the other CUDA 12 rungs. No other range changes.
Splitting requirements.lock.txt in two created an invariant nothing enforced: the files must stay identical apart from the sympy pin, because any other drift makes pip resolve the mismatch by replacing a base image's torch with a PyPI build. Dependabot updates both files independently, so a bump landing in one and not the other would reintroduce exactly the regression the split exists to prevent, and no test would notice. The first gate asserts the two locks differ only in that pin, and that the newer torch's lock carries the newer version -- an accidental swap breaks both images while a bare inequality check would pass it. The versions themselves are not hardcoded here: the pin legitimately moves when a base image does, and a third file needing lockstep edits is how gates go stale. The second gate protects .github/dependabot.yml, which nothing validated; GitHub only reports a malformed file after a push. Parsing it is not enough. A dropped list dash makes an entry merge into its predecessor as a duplicate key, which yaml.safe_load accepts while silently deleting an ignore entry -- that slip once removed tokenizers with every naive check still green. The gate therefore counts list-item markers as text and asserts the pip ignore set by name, so a merged entry cannot hide behind a successful parse.
Two published images became three, and the choice between the two CUDA tags is now something a user has to make from their card's generation -- so the documentation has to state which architectures each one covers, not just that a GPU variant exists. Since no compose overlay pins the legacy tag, the documented `image:` edit is the only route to it, and it has to name all four GPU overlays rather than the three it first listed. The image-size figures were also stale and are now measured: 3.34 GB, 13.1 GB and 13.8 GB unpacked, against a table that had claimed roughly 21 GB for a CUDA image. The compressed-download column is dropped rather than left carrying a placeholder, because real transfer sizes are only knowable once these images are published; it returns with figures then. Two corrections beyond the mechanical update. `docker-compose.legacy.yml` selects the CPU-only `legacy` VRAM profile and has nothing to do with the `-cuda-legacy` architecture tag, which is a collision a reader will hit while looking for exactly this; that is now called out where it will be read. And "any card older than Turing" was wrong in the direction that matters: the legacy image floors at sm_50, so Kepler and Fermi owners were being sent to an image that fails identically. They are now pointed at the CPU profile instead. All five translations carry the same changes.
Issue #119's gate landed in ScoringConfig.detect_gpu_vram_gb but not in its sibling ModelManager.detect_vram, so the two disagreed about the same machine: on a card this PyTorch build ships no kernels for, get_device() returned cpu while detect_vram() still reported the card's full VRAM. MultiPassProcessor reads both, and a non-zero VRAM makes ram_budgeted false -- so the run logged "GPU VRAM: 16.0GB", grouped every model into one pass, admitted the VLM tagger, and skipped _apply_ram_safe_chunk_start, the guard that exists to stop an OOM when model memory comes out of system RAM. The crash moved from the GPU to the CPU fallback rather than going away. OCR was worse than a mis-sized budget. easyocr.Reader(gpu=True) only moves weights, which is a memcpy and succeeds, so the missing cubin surfaced inside every readtext -- where extract_text swallows it and returns None. On such a card OCR silently produced nothing for an entire library, when gpu=False would have worked on the CPU. The /metrics gauges and the resource monitor also published live VRAM for a card Facet had just refused to use, contradicting --doctor on the same box. Both now ask the same question. get_vram_usage keys off self.device, which is already get_device()'s answer, rather than re-deriving it. The remaining raw torch.cuda.is_available() calls are deliberate: diagnostics.py reports torch's own answer by design, and the two empty_cache() unload guards make no execution decision.
The wheel-index ladder read only nvidia-smi's "CUDA Version", which reports what the DRIVER supports and says nothing about the silicon behind it. A GTX 1080 (sm_61) or Titan V (sm_70) on a current driver reports 12.8 exactly as an RTX 5090 does, took the >= 12.8 rung, and installed a cu128 wheel whose compiled architectures start at sm_75 -- verified from the wheel itself, which reports "sm_70 sm_75 sm_80 sm_86 sm_90 sm_100 sm_120" on 2.10.0+cu128 and sm_75 upward on the 2.11 line. That card then died with the same "no kernel image is available for execution on the device" this branch exists to eliminate, reached from the opposite direction. install.sh now also reads the lowest installed card's compute capability and floors the choice at cu126 (sm_50-sm_90) below sm_75, which is the bare metal twin of the latest-cuda / latest-cuda-legacy image split. A machine whose capability nvidia-smi is too old to report keeps the previous driver-only behaviour rather than guessing. The manual-install instructions carried the same driver-only rule, so the caveat is added there and in all five translations.
… test Building with load:true and publishing with a hand-rolled `docker push` loop kept the smoke test a gate but changed what gets published. buildx attaches an in-toto SLSA provenance attestation and pushes an OCI index; the daemon pushes a bare Docker v2 manifest with neither. Every Facet image on GHCR today carries that attestation -- both `latest` and `latest-cuda` return an image index with an attestation-manifest -- so the loop would have silently dropped it from all three tags, leaving `cosign verify-attestation` and `gh attestation verify` with no subject and no way to tell a release build from an image pushed by hand with a leaked packages:write token. It also changed the manifest media type, which breaks any consumer expecting the index shape. The Push step is build-push-action again, with provenance and an SBOM. It does not build twice: every layer is already in the GHA cache from the Build step above, so it re-exports and uploads. The ordering that made the loop attractive is unchanged -- Push still runs only after the smoke test passes, so no image is published before something has run it.
The lock and dependabot gates were written to fail loudly and did not.
Under GitHub's default `bash -e {0}`, `count=$(grep -c ...)` aborts the
step the moment a count is zero -- which is precisely the case the
ecosystem loop exists to catch. A deleted block gave a bare red X with none
of the crafted message, so `|| true` now keeps the value and lets the
explicit check speak.
The sympy comparison assumed both greps matched. A pin that is missing, or
written in a form the pattern rejects, leaves both variables empty and
therefore EQUAL, and the gate then reported "pinned to the same version ()"
-- sending the reader after a pin that is not there. An emptiness check now
runs first.
The ignore-list assertion covered pip only, while the entry that pins ARG
BASE_IMAGE lives in the docker block. Dropping its dash still parses:
yaml.safe_load returns success with pytorch/pytorch gone. Nothing else
would notice, because docker-publish.yml runs on v* tags only, so its
expect_arch smoke test never sees a Dependabot base-image bump until
release. The assertion is now a function called for pip and docker; npm and
github-actions stay unasserted, since neither pins a build decision and
gating lists that legitimately churn is how a gate becomes noise. The
ecosystem count runs first so a deleted block reads as "found 0" rather
than as an empty ignore list.
Adding the arch-mismatch report left three verbatim copies of the same two
pip commands in diagnostics.py -- the new one, the simulate branch and the
nvidia-smi branch -- and the cu124 to cu126 bump in this branch had to
touch two of them separately. They now go through one helper that takes its
lead-in line, so the next index change has a single site.
"GPU unusable by this PyTorch build" was hand-typed four times across two
files and had already drifted ("No GPU detected" against "No GPU does not
support"). It is now a constant beside CudaArchStatus, with the two profile
checks sharing one _gpu_absence_label, and the sm_XY rendering that
appeared in both files is a named helper.
_compute_cuda_arch_status builds the mismatch reason inside the branch that
establishes the capability rather than recomputing the condition into a
bool, which drops one mypy error: the reason needs a non-optional
capability, and a stored bool cannot narrow it.
Every arch list in the suite was two-digit and every negative case differed in the major, so sm_120 -- the one parse this whole branch exists for -- was never exercised. Breaking the digit split so sm_120 reads as major 1 minor 20 left the full suite green while the correct Blackwell image would report its own GPU unusable and demote every scan to CPU. The same held for the weakening direction of the minor comparison: only the positive case (an sm_60 cubin covering an sm_61 card) was pinned, so dropping the comparison entirely also stayed green. The probe's synchronize had no test either, because every double drove its failure through zeros or add_. On real hardware the launch is asynchronous and the synchronize is what surfaces a missing cubin, so the double now takes a sync hook and both the disqualifying and the fail-open cases are pinned there. Three diagnostics tests asserted things that could not fail. The memoisation test counted probes for one module, which a globally-keyed cache satisfies; the doctor test asserted a label whose value renders identically whatever the diagnosis says; and the tag test pinned latest-cuda-legacy and cu126 for an sm_120 card -- the fallback lines, not the two that resolve the issue. The verbose branch of check_vram_profile_compatibility, which is the one a real run takes, had no test at all. Each new assertion was confirmed to go red on an injected fault and green again once it was reverted.
The install.sh fix is user-visible on its own: a pre-Turing card on a modern driver used to get a wheel with no kernels for it, and now gets cu126. The device-gate entry claimed only --doctor and device selection, which understated it -- VRAM sizing, OCR and the two telemetry surfaces ask the same question now, which is what stops a machine being reported as running on CPU and budgeted as a 16 GB GPU at the same time. doctor.log is ignored because docker-publish.yml's smoke test writes it into the workspace, and running the same command by hand from the repo root leaves it there untracked.
Five open Dependabot alerts, all the same transitive dev dependency: undici >= 7.0.0 < 7.29.0, reached through @angular/build and jsdom. One is high (CVE-2026-13697, cross-user disclosure and a parse-time crash via degenerate private cache directives) and four are medium (CRLF injection via a blob-like body type, Cache-Control whitespace disclosure, cookie attribute injection, and response desynchronisation in the retry interceptor). All five are fixed in 7.29.0. `npm audit fix --force` wanted @angular/build 21.2.22, outside the exact 21.2.19 this project pins -- and the Angular bump already in flight (Dependabot #109) only reaches 21.2.21, so it would not have closed these either. An overrides entry patches the transitive dependency without touching the framework pin, which is what this repo already does for ws. The lock is edited surgically rather than regenerated. A plain `npm install` here drops @emnapi/core and @emnapi/runtime -- optional, platform-gated entries this machine does not need and a runner does -- and `npm ci` then refuses the tree with "Missing: @emnapi/core@1.11.3 from lock file". So the only changes are undici 7.28.0 -> 7.29.0 and the removal of node-gyp's nested 6.28.0 copy, which the override dedupes into the root; every other entry is byte-identical to before. Verified with the command that failed: npm ci, then 1969 tests, ng lint, both tsconfig typechecks and a production ng build, plus npm audit reporting zero vulnerabilities.
ncoevoet
force-pushed
the
fix/issue-119-blackwell-cuda-image
branch
from
September 1, 2026 13:22
e0a16e9 to
404d297
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An RTX 50-series card ran
facet.py, sailed through detection, was assigned the 16gb profile, and died on its first real tensor with "no kernel image is available for execution on the device." The publishedlatest-cudaimage was pinned to a torch that shipssm_50–sm_90cubins; Blackwell issm_120.Three things had to change, and each uncovered the next.
The image.
latest-cudanow builds onpytorch/pytorch:2.11.0-cuda12.8-cudnn9-runtime(sm_75–sm_120). That wheel dropssm_50/sm_60/sm_70, so Maxwell, Pascal and Volta cards get their own tag,latest-cuda-legacy, on the previous base. Two published images became three.The detection.
torch.cuda.is_available()answers "is there a driver and a device" and nothing else —get_arch_list()had zero occurrences in the tree, and no code allocated so much as a test tensor before committing a VRAM profile. Facet now compares the device's compute capability against the build's architecture list and launches one throwaway kernel first. A card the build cannot run falls back to CPU and names the image tag or wheel index to switch to.The bare-metal installer.
install.shchose its wheel from the driver's CUDA version alone. A GTX 1080 behind a modern driver reports "CUDA Version: 12.8" exactly as an RTX 5090 does, so it installed cu128 and died the same death from the opposite direction.Two unrelated regressions surfaced on the way and are fixed here. The published CPU image shipped a full CUDA build of PyTorch — measured at
torch 2.6.0+cu124, 13nvidia-*wheels, 8.81 GB — because asympy==1.13.1pin made pip discard the CPU-only torch to satisfy the resolver. Rebuilt:torch 2.13.0+cpu, zeronvidia-*, 3.34 GB. And the runtime UID silently moved from 1000 to 1001 on the new Ubuntu 24.04 base, which would have opened an existing install's SQLite database read-only on the nextdocker compose pull.Root cause
is_available()conflates a device exists with this wheel can run on it. Those are different questions, and CUDA answers the second only when a kernel actually launches — which is why the gate reads the arch list and executes one, with the executed kernel winning.The rule is deliberately an allow-list. Only a definite architecture mismatch, or a failure carrying CUDA's own
no kernel image/invalid device functionsignature, marks a GPU unusable. An out-of-memory, a card held in exclusive mode, an unfamiliar CUDA error — all fail open and surface where they always did, at model load. A deny-list of the failures we happen to know about would demote a working GPU to CPU for the process lifetime and blame the install for it.Splitting the dependency lock in two created an invariant nothing enforced: the files must stay identical apart from the
sympypin, because any other drift makes pip resolve the mismatch by replacing a base image's torch with a PyPI build — the 8.81 GB regression above, running backwards. Two CI gates now hold that line, plus thedependabot.ymlignore lists, where a dropped list dash merges an entry into its predecessor andyaml.safe_loadaccepts it.Review pass
This branch was reviewed before opening, and roughly half the diff is what that found. The theme was one shape: the migration off
is_available()was incomplete, and CI could not see the fix break.ModelManager.detect_vram()was left on the old predicate while its siblingScoringConfig.detect_gpu_vram_gbwas migrated. On a Blackwell box the two then disagreed —get_device()saidcpu,detect_vram()reported the card's full VRAM — so the CPU fallback was budgeted as a 16 GB GPU run and skipped_apply_ram_safe_chunk_start, the guard against exactly that OOM.easyocr.Reader(gpu=True)on the same predicate. Moving weights is a memcpy and succeeds, so the missing cubin landed inside everyreadtext, whichextract_textswallows — OCR returned nothing for an entire library wheregpu=Falsewould have worked./metricsgauges and the resource monitor published live VRAM for a card Facet had just refused to use.bash -e {0},count=$(grep -c ...)aborts the step before its own error message can print — the case the loop existed to catch — and thesympycomparison read a missing pin as "pinned to the same version ()".piponly, while the entry pinningARG BASE_IMAGElives in thedockerblock.build-push-actionto a hand-rolleddocker push, which silently drops the SLSA provenance attestation every Facet image currently carries. Restored, while keeping the smoke test a gate rather than a post-mortem.overridesentry, as this repo already does forws.npm audit fix --forcewanted@angular/build 21.2.22, outside the exact21.2.19pinned here, and Dependabot build(deps): bump the angular group across 1 directory with 10 updates #109 only reaches21.2.21— neither would have closed them.CodeQL alert #120 (
py/command-line-injection, critical) was investigated and dismissed as a false positive:cmdis a list with noshell=True; the only request-derived argv value is the photo path, which is DB-validated, thenos.path.realpath'd — always absolute, so it can never be parsed as an option flag (realpath('--core')→/abs/cwd/--core) — and scan-directory contained;profile/styleare equality-matched against config allowlists; and the dangerousexecutable/extra_argsfields live inscoring_config.json, which none of the four config-writing endpoints ever touch.Verification
ruff(both the project config and CI'sE,F,W),check_packaging.py,audit_i18n.py,ng lint, both tsconfig projects,ng buildandgen:apiall clean.HEADmeasurement of 162 in the same venv. Measured in a detached worktree rather than assumed, because the script's own docstring warns the count is environment-dependent.mypy-baseline.txtis left at 154, which tracks CI's dependency-free environment and cannot be measured locally.sm_120parses as major 1 minor 20 — the exact regression that would make the correct Blackwell image reject its own GPU — left the previous suite fully green; it now turns two tests red. Same for dropping the minor comparison, deleting the probe'ssynchronize, defeating the per-module memo key, blanking the mismatch reason, deleting the two lines that actually resolve RTX 50-series (Blackwell / sm_120): published CUDA image detects the GPU but cannot run kernels (torch 2.6+cu126) #119, and making theverbosebranch raise. Each fault was reverted by retyping and the file confirmed byte-identical, never bygit checkout.yaml.safe_loadaccepts whilepytorch/pytorchvanishes now fails the build, where before it printed "intact" and exited 0.torch._C._cuda_getArchFlags()on an installed cu128 wheel reportssm_70 sm_75 sm_80 sm_86 sm_90 sm_100 sm_120.latestandlatest-cudacurrently return an OCI index carrying anattestation-manifest.HEAD).Note that
docker-publish.ymlruns onv*tags only, so the three-image matrix, the UID assertion and the arch smoke test do not execute on this PR — they first run at release, or viaworkflow_dispatch.Closes #119.