CPU and GPU screening¶
Graph matching in SMSD Pro, which covers every substructure, SMARTS and MCS
search, always runs on the CPU. An NVIDIA GPU can take over screening steps:
the ECFP4 screens of a prepared TargetCorpus, the pre-screen of a corpus
search and the scans of a packed fingerprint index. Hits, scores and search
decisions are identical on either device. Every Python program on this page
runs on a CPU-only machine; the GPU branches run only where a device is ready.
What runs where¶
| Operation | CPU | NVIDIA GPU (smsd-cuda) |
How it is chosen |
|---|---|---|---|
| Substructure, SMARTS and MCS matching | Always | Never | Not applicable |
Fingerprints, batch_similarity and similarity coefficients |
Always | Never | Not applicable |
screen_targets, TargetCorpus.screen_targets_many, and Substructure.match_many on a TargetCorpus |
Always | Never with smsd-cuda 8.1.0 |
Not applicable |
ECFP4 screens of a prepared TargetCorpus: screen, screen_many, warm_start, stream_screen_smiles and stream_screen_many_smiles |
Below the device rule, without a usable device, or after a device failure | A Tesla T4 for 100,000 targets or more, or any supported device from SMSD_CUDA_MIN_TARGETS targets |
Automatically, for each screen |
Pre-screen of Substructure.match_corpus and MCS.match_corpus |
gpu_prefilter=False (the default), or without a usable device |
gpu_prefilter=True with an attached device |
By the caller, with CPU fallback |
Packed FingerprintCorpus scans, butina and maxmin |
backend="cpu" (the default), or "auto" without a usable device |
backend="cuda", or "auto" with a usable device |
By the caller; "cuda" raises without a device |
The searches that follow a pre-screen, and every match, match_all and
match_many call, use the CPU. Python never uses Metal; see
Metal and C++.
The smsd package looks for smsd-cuda once per process, the first time a
prepared screen, warm_start, a gpu_prefilter=True search,
gpu_is_available() or gpu_device_info() needs it. Importing smsd does
not load the CUDA runtime, so the first such call carries the device start-up
cost; smsd_cuda.preload() moves that cost to a point the program chooses.
Check the backend¶
smsd.gpu_is_available() returns True when an attached smsd-cuda package
has found a usable device. smsd.gpu_device_info() describes the backend: on
a CPU-only machine it starts with CPU: and names the OpenMP version and
thread count; with an attached device it starts with GPU:, followed by the
device name, its compute capability, its memory and, in brackets, the CPU
threading. Both report what is available, not which device a call used.
The cuda_status() helper below is the one in the
GPU screening example. It decides
whether backend="cuda" can be used and gives the reason when it cannot.
import os
import smsd
try:
import smsd_cuda
except ImportError:
smsd_cuda = None
def cuda_status():
"""Return whether backend="cuda" can be used here, and the reason when it cannot."""
if os.environ.get("SMSD_DISABLE_GPU", "") not in ("", "0"):
return False, "disabled by SMSD_DISABLE_GPU"
if smsd_cuda is None:
return False, "smsd-cuda is not installed"
if smsd_cuda.__version__ != smsd.__version__:
return False, (f"smsd-cuda {smsd_cuda.__version__} does not match "
f"smsd {smsd.__version__}")
reason = smsd_cuda.unavailable_reason()
return reason is None, reason
ready, reason = cuda_status()
print("CUDA screening:", "ready" if ready else f"unavailable ({reason})")
assert ready or reason
available = smsd.gpu_is_available()
print("GPU available:", available)
assert isinstance(available, bool)
# 'CPU: ...' without a device; 'GPU: <device> [...]' once smsd-cuda is attached.
backend = smsd.gpu_device_info()
assert isinstance(backend, str) and backend
if ready:
smsd_cuda.preload() # initialise the device and load its kernels now
assert "compute" in smsd_cuda.device_info()
On a machine without smsd-cuda, the program prints:
With smsd-cuda and a usable device, the program prints
CUDA screening: ready and GPU available: True. Otherwise
smsd_cuda.unavailable_reason() explains why, for example
no CUDA device was found, or a reason that names a device below compute
capability 7.5.
Install the CUDA package¶
The smsd-cuda package adds NVIDIA screening to smsd on Linux x86_64.
Install it with the same version of smsd:
Packed indexes and clustering also need NumPy, which neither package installs:
- The package needs Linux x86_64 and CPython 3.12–3.15; the supported-platforms table gives the C library version.
- The device must be an NVIDIA GPU of compute capability 7.5 or later, with a driver compatible with the CUDA 12 runtime. The runtime is installed as a dependency; the CUDA toolkit is not needed.
- The versions must match exactly.
smsd-cuda8.1.0 requiressmsd8.1.0, andsmsddoes not attach a package built for another version. - No
smsd-cudawheel is published for Windows or macOS. On Windows, the GPU container runs through WSL2; see Containers.
Prepared screens, the stream functions and match_corpus need no NumPy.
Automatic use in prepared screens¶
Prepared ECFP4 screens choose their device automatically.
TargetCorpus.screen and screen_many after prewarm(), warm_start, and
the stream functions stream_screen_smiles and stream_screen_many_smiles
use the GPU when smsd-cuda is attached and the corpus is large enough.
Otherwise, and whenever device work fails, they run on the CPU with the same
hits. The code is the same on every machine.
By default the GPU is used only on a Tesla T4 (compute capability 7.5), and
only for a prepared corpus of at least 100,000 targets. Setting
SMSD_CUDA_MIN_TARGETS to a positive whole number N lets any supported device
take corpora of N targets or more; on a Tesla T4, N replaces 100,000. The
stream functions apply the rule to each chunk of targets (4,096 by default),
so they use the device only when a chunk is large enough.
import os
import smsd
from smsd.corpus import stream_screen_smiles
benzodiazepines = {
"diazepam": "CN1C(=O)CN=C(c2ccccc2)c2cc(Cl)ccc21",
"nordazepam": "O=C1CN=C(c2ccccc2)c2cc(Cl)ccc2N1",
"temazepam": "CN1C(=O)C(O)N=C(c2ccccc2)c2cc(Cl)ccc21",
"oxazepam": "OC1N=C(c2ccccc2)c2cc(Cl)ccc2NC1=O",
"lorazepam": "OC1N=C(c2ccccc2Cl)c2cc(Cl)ccc2NC1=O",
"clonazepam": "O=C1CN=C(c2ccccc2Cl)c2cc([N+](=O)[O-])ccc2N1",
"nitrazepam": "O=C1CN=C(c2ccccc2)c2cc([N+](=O)[O-])ccc2N1",
"flunitrazepam": "CN1C(=O)CN=C(c2ccccc2F)c2cc([N+](=O)[O-])ccc21",
"bromazepam": "O=C1CN=C(c2ccccn2)c2cc(Br)ccc2N1",
"tetrazepam": "CN1C(=O)CN=C(C2=CCCCC2)c2cc(Cl)ccc21",
"alprazolam": "Cc1nnc2n1-c1ccc(Cl)cc1C(c1ccccc1)=NC2",
"triazolam": "Cc1nnc2n1-c1ccc(Cl)cc1C(c1ccccc1Cl)=NC2",
"midazolam": "Cc1ncc2n1-c1ccc(Cl)cc1C(c1ccccc1F)=NC2",
"clobazam": "CN1C(=O)CC(=O)N(c2ccccc2)c2cc(Cl)ccc21",
}
names = list(benzodiazepines)
library = list(benzodiazepines.values())
queries = [benzodiazepines["diazepam"], benzodiazepines["alprazolam"]]
# The same calls on every machine; each screen chooses its device.
corpus = smsd.TargetCorpus.from_smiles(library)
corpus.prewarm()
hits = corpus.screen_many(queries, 0.4)
for query, found in zip(("diazepam", "alprazolam"), hits):
print(f"{query}:", ", ".join(names[index] for index in found))
assert hits == [[0, 1, 2, 9, 13], [10, 11, 12]]
# warm_start prepares the corpus it is given; streams prepare one chunk at a time.
fresh = smsd.TargetCorpus.from_smiles(library)
assert smsd.warm_start(fresh, queries[0], 0.4) == hits[0]
streamed = list(stream_screen_smiles(queries[0], iter(library), 0.4, chunk_size=5))
assert streamed == hits[0]
# Let any supported GPU take corpora of one target or more; the CPU ignores it.
os.environ["SMSD_CUDA_MIN_TARGETS"] = "1"
same = corpus.screen_many(queries, 0.4) == hits
print("same hits with SMSD_CUDA_MIN_TARGETS=1:", same)
assert same
The program prints:
diazepam: diazepam, nordazepam, temazepam, tetrazepam, clobazam
alprazolam: alprazolam, triazolam, midazolam
same hits with SMSD_CUDA_MIN_TARGETS=1: True
A CPU-only machine ignores SMSD_CUDA_MIN_TARGETS. No public record shows
which device ran a prepared screen; the hits are the same either way.
Request the GPU for a corpus pre-screen¶
Substructure.match_corpus and MCS.match_corpus rule out, with a proof,
the targets that cannot contain the query or cannot share minimum_atoms
atoms with it, and search the remaining targets on the CPU. With
gpu_prefilter=True they ask an attached device to run that pre-screen,
whatever the corpus size. Without a usable device, or if device work fails,
the CPU pre-screen runs instead. Decisions, bounds and matches are identical
either way, and no prewarm() is needed.
SMARTS queries, and searches with match_atom_type=False, a node limit, an
immediate timeout or a cancellation token, search every target on the CPU
without a pre-screen. MCS searches do the same for MCES, atom weights and
excluded target atoms.
import smsd
benzodiazepines = {
"diazepam": "CN1C(=O)CN=C(c2ccccc2)c2cc(Cl)ccc21",
"nordazepam": "O=C1CN=C(c2ccccc2)c2cc(Cl)ccc2N1",
"temazepam": "CN1C(=O)C(O)N=C(c2ccccc2)c2cc(Cl)ccc21",
"oxazepam": "OC1N=C(c2ccccc2)c2cc(Cl)ccc2NC1=O",
"lorazepam": "OC1N=C(c2ccccc2Cl)c2cc(Cl)ccc2NC1=O",
"clonazepam": "O=C1CN=C(c2ccccc2Cl)c2cc([N+](=O)[O-])ccc2N1",
"nitrazepam": "O=C1CN=C(c2ccccc2)c2cc([N+](=O)[O-])ccc2N1",
"flunitrazepam": "CN1C(=O)CN=C(c2ccccc2F)c2cc([N+](=O)[O-])ccc21",
"bromazepam": "O=C1CN=C(c2ccccn2)c2cc(Br)ccc2N1",
"tetrazepam": "CN1C(=O)CN=C(C2=CCCCC2)c2cc(Cl)ccc21",
"alprazolam": "Cc1nnc2n1-c1ccc(Cl)cc1C(c1ccccc1)=NC2",
"triazolam": "Cc1nnc2n1-c1ccc(Cl)cc1C(c1ccccc1Cl)=NC2",
"midazolam": "Cc1ncc2n1-c1ccc(Cl)cc1C(c1ccccc1F)=NC2",
"clobazam": "CN1C(=O)CC(=O)N(c2ccccc2)c2cc(Cl)ccc21",
}
names = list(benzodiazepines)
corpus = smsd.TargetCorpus.from_smiles(list(benzodiazepines.values()))
def decisions(results):
"""Each target's decision, atom bound and matches, leaving out timings."""
return [(item.target_index, item.atom_upper_bound, item.threshold_reached,
item.proven_below_threshold, None if item.result is None else
(item.result.status, item.result.optimal, item.result.complete,
item.result.matches))
for item in results]
# 5-phenyl-1,4-benzodiazepin-2-one core
core = smsd.Substructure.find("O=C1CN=C(c2ccccc2)c2ccccc2N1")
on_cpu = core.match_corpus(corpus)
requested = core.match_corpus(corpus, gpu_prefilter=True)
assert decisions(requested) == decisions(on_cpu)
found = [names[item.target_index] for item in on_cpu if item.threshold_reached]
ruled_out = [names[item.target_index] for item in on_cpu if item.result is None]
print("core found in", len(found), "targets")
print("ruled out before matching:", ", ".join(ruled_out))
assert len(found) == 9
assert ruled_out == ["alprazolam", "triazolam", "midazolam", "clobazam"]
# Targets that share all 22 atoms of alprazolam
mcs = smsd.MCS.find(benzodiazepines["alprazolam"])
on_cpu = mcs.match_corpus(corpus, 22)
requested = mcs.match_corpus(corpus, 22, gpu_prefilter=True)
assert decisions(requested) == decisions(on_cpu)
shared = [names[item.target_index] for item in on_cpu if item.threshold_reached]
print("all 22 atoms shared:", ", ".join(shared))
assert shared == ["alprazolam", "triazolam"]
The program prints:
core found in 9 targets
ruled out before matching: alprazolam, triazolam, midazolam, clobazam
all 22 atoms shared: alprazolam, triazolam
A target that was ruled out has result set to None and
proven_below_threshold set to True. The other CorpusSearchResult fields
are described in
Screening a library.
Choose a backend for packed indexes¶
The searches of a packed FingerprintCorpus (similarities, screen_many,
threshold_pairs and top_k_pairs) and the clustering functions butina
and maxmin take a backend argument:
| Backend | Behaviour |
|---|---|
"cpu" |
The default. The scan runs on the CPU and does not load smsd-cuda. |
"cuda" |
The scan runs on the GPU. It raises RuntimeError when no device is usable and ValueError for fingerprints wider than 65,536 bits. |
"auto" |
The scan runs on the GPU whenever a device is usable and the width is supported, for an index of any size, and on the CPU otherwise. |
Hits, scores, their order, clusters and selections are identical for all
three. Once device work has started, an error is raised rather than retried
on the CPU, under "auto" as well. A packed index needs NumPy; see
Packed fingerprint index for building and reopening one.
import os
from pathlib import Path
from tempfile import TemporaryDirectory
import smsd
from smsd.clustering import butina, maxmin
from smsd.corpus import FingerprintCorpus
try:
import smsd_cuda
except ImportError:
smsd_cuda = None
def cuda_status():
"""Return whether backend="cuda" can be used here, and the reason when it cannot."""
if os.environ.get("SMSD_DISABLE_GPU", "") not in ("", "0"):
return False, "disabled by SMSD_DISABLE_GPU"
if smsd_cuda is None:
return False, "smsd-cuda is not installed"
if smsd_cuda.__version__ != smsd.__version__:
return False, (f"smsd-cuda {smsd_cuda.__version__} does not match "
f"smsd {smsd.__version__}")
reason = smsd_cuda.unavailable_reason()
return reason is None, reason
benzodiazepines = {
"diazepam": "CN1C(=O)CN=C(c2ccccc2)c2cc(Cl)ccc21",
"nordazepam": "O=C1CN=C(c2ccccc2)c2cc(Cl)ccc2N1",
"temazepam": "CN1C(=O)C(O)N=C(c2ccccc2)c2cc(Cl)ccc21",
"oxazepam": "OC1N=C(c2ccccc2)c2cc(Cl)ccc2NC1=O",
"lorazepam": "OC1N=C(c2ccccc2Cl)c2cc(Cl)ccc2NC1=O",
"clonazepam": "O=C1CN=C(c2ccccc2Cl)c2cc([N+](=O)[O-])ccc2N1",
"nitrazepam": "O=C1CN=C(c2ccccc2)c2cc([N+](=O)[O-])ccc2N1",
"flunitrazepam": "CN1C(=O)CN=C(c2ccccc2F)c2cc([N+](=O)[O-])ccc21",
"bromazepam": "O=C1CN=C(c2ccccn2)c2cc(Br)ccc2N1",
"tetrazepam": "CN1C(=O)CN=C(C2=CCCCC2)c2cc(Cl)ccc21",
"alprazolam": "Cc1nnc2n1-c1ccc(Cl)cc1C(c1ccccc1)=NC2",
"triazolam": "Cc1nnc2n1-c1ccc(Cl)cc1C(c1ccccc1Cl)=NC2",
"midazolam": "Cc1ncc2n1-c1ccc(Cl)cc1C(c1ccccc1F)=NC2",
"clobazam": "CN1C(=O)CC(=O)N(c2ccccc2)c2cc(Cl)ccc21",
}
names = list(benzodiazepines)
options = smsd.FingerprintOptions.ecfp(radius=2, size=2048)
ready, reason = cuda_status()
with TemporaryDirectory() as directory:
path = Path(directory) / "benzodiazepines.fp"
with FingerprintCorpus.from_smiles(path, list(benzodiazepines.values()),
options=options) as corpus:
query = smsd.fingerprint(benzodiazepines["diazepam"], options=options)
hits = list(corpus.similarities(query, threshold=0.4, backend="cpu"))
named = [(names[index], round(score, 3)) for index, score in hits]
for name, score in named:
print(f"{name:<11} {score:.3f}")
assert named == [("diazepam", 1.0), ("nordazepam", 0.528),
("temazepam", 0.582), ("tetrazepam", 0.542),
("clobazam", 0.5)]
print("CUDA calls after the CPU scan:", corpus.cuda_calls)
assert corpus.cuda_calls == 0
# "auto" may run on either device, so check its results, not where it ran.
automatic = list(corpus.similarities(query, threshold=0.4, backend="auto"))
assert automatic == hits
clusters = butina(corpus, threshold=0.4, backend="auto")
assert clusters == butina(corpus, threshold=0.4, backend="cpu")
picked = maxmin(corpus, count=3, backend="auto")
assert picked == maxmin(corpus, count=3, backend="cpu")
if ready:
before = corpus.cuda_calls
on_gpu = list(corpus.similarities(query, threshold=0.4, backend="cuda"))
assert on_gpu == hits
assert corpus.cuda_calls > before # the device completed the scan
assert butina(corpus, threshold=0.4, backend="cuda") == clusters
assert maxmin(corpus, count=3, backend="cuda") == picked
The program prints:
diazepam 1.000
nordazepam 0.528
temazepam 0.582
tetrazepam 0.542
clobazam 0.500
CUDA calls after the CPU scan: 0
The branch under if ready: runs only where backend="cuda" can be used;
on any other machine the program prints the same lines.
Keep work on the CPU¶
Three switches keep work on the CPU:
SMSD_DISABLE_GPU, set to any value other than an empty string or0, disables every GPU route in the process. Prepared screens and pre-screens run on the CPU,"auto"uses the CPU andbackend="cuda"raises. The variable is read at each call, so a program can set and remove it as it runs. It is the only switch for prepared screens.gpu_prefilter=False, the default, keeps a corpus pre-screen on the CPU.backend="cpu", the default, keeps a packed scan or clustering run on the CPU.
import os
from pathlib import Path
from tempfile import TemporaryDirectory
import smsd
from smsd.corpus import FingerprintCorpus
os.environ["SMSD_DISABLE_GPU"] = "1"
available = smsd.gpu_is_available()
print("GPU available:", available)
assert available is False
marked = smsd.gpu_device_info().endswith("[GPU disabled]")
print("backend string ends with [GPU disabled]:", marked)
assert marked
# Prepared screens and pre-screens run on the CPU with their usual results.
corpus = smsd.TargetCorpus.from_smiles(["CCO", "CCN", "c1ccccc1O"])
corpus.prewarm()
assert corpus.screen("CCO", 1.0) == [0]
search = smsd.Substructure.find("c1ccccc1O")
assert search.match_corpus(corpus, gpu_prefilter=True)[2].threshold_reached
with TemporaryDirectory() as directory:
with FingerprintCorpus.create(Path(directory) / "bits.fp", [[1], [1, 2], [3]],
fp_size=8) as packed:
hits = list(packed.similarities([1], threshold=0.5, backend="auto"))
print("auto:", hits, "| CUDA calls:", packed.cuda_calls)
assert hits == [(0, 1.0), (1, 0.5)] and packed.cuda_calls == 0
# The variable is read at each call, so removing it allows the GPU again.
del os.environ["SMSD_DISABLE_GPU"]
marked = smsd.gpu_device_info().endswith("[GPU disabled]")
print("after removing the variable:", marked)
assert not marked
The program prints:
GPU available: False
backend string ends with [GPU disabled]: True
auto: [(0, 1.0), (1, 0.5)] | CUDA calls: 0
after removing the variable: False
Fallback and errors¶
The routes treat a missing or failing device differently:
| Route | No usable device | Error during device work |
|---|---|---|
Prepared screens, warm_start and the stream functions |
The CPU | The CPU, silently, with the same hits |
match_corpus(..., gpu_prefilter=True) |
The CPU pre-screen | The CPU pre-screen, with the same decisions |
Packed backend="auto" |
The CPU | The error is raised |
Packed backend="cuda" |
RuntimeError |
The error is raised |
The packed searches are generators. A backend="cuda" request that cannot be
met raises when the first item is requested, not when the method is called,
so keep the iteration inside the try block. The program below sets
SMSD_DISABLE_GPU so that its output is the same on every machine.
import os
from pathlib import Path
from tempfile import TemporaryDirectory
import smsd
from smsd.corpus import FingerprintCorpus
# Make the device unusable, so that every machine prints the same lines.
os.environ["SMSD_DISABLE_GPU"] = "1"
with TemporaryDirectory() as directory:
with FingerprintCorpus.create(Path(directory) / "bits.fp", [[1], [1, 2], [3]],
fp_size=8) as corpus:
pending = corpus.similarities([1], threshold=0.5, backend="cuda")
print("the call returns a", type(pending).__name__)
assert type(pending).__name__ == "generator"
try:
next(pending)
except RuntimeError as error:
message = str(error)
else:
raise AssertionError("backend='cuda' should fail without a device")
print("first item:", message)
assert message.endswith("CUDA screening is disabled by SMSD_DISABLE_GPU")
# Fingerprints wider than 65,536 bits stay on the CPU under "auto".
with FingerprintCorpus.create(Path(directory) / "wide.fp", [[1], [70000]],
fp_size=70001) as wide:
hits = list(wide.similarities([70000], threshold=0.5, backend="auto"))
print("auto, 70,001 bits:", hits)
assert hits == [(1, 1.0)]
try:
list(wide.similarities([70000], threshold=0.5, backend="cuda"))
except ValueError as error:
message = str(error)
else:
raise AssertionError("backend='cuda' should refuse 70,001 bits")
print("cuda, 70,001 bits:", message)
assert message == "CUDA packed screening supports at most 65536 bits"
# Argument errors are raised at once, whatever the machine.
corpus = smsd.TargetCorpus.from_smiles(["CCO", "CCN"])
try:
smsd.Substructure.find("CO").match_corpus(corpus, gpu_prefilter=1)
except TypeError as error:
message = str(error)
print("gpu_prefilter=1:", message)
assert message == "GPU prefilter flag must be a bool"
The program prints:
the call returns a generator
first item: The requested CUDA packed-screening backend is unavailable: CUDA screening is disabled by SMSD_DISABLE_GPU
auto, 70,001 bits: [(1, 1.0)]
cuda, 70,001 bits: CUDA packed screening supports at most 65536 bits
gpu_prefilter=1: GPU prefilter flag must be a bool
The RuntimeError message gives the cause after the colon:
No module named 'smsd_cuda' when the package is missing,
smsd and smsd-cuda must have the same version for a version mismatch,
no CUDA device was found, or a reason that names a device below compute
capability 7.5. A backend value other than "cpu", "cuda" or "auto"
raises ValueError.
If the installed smsd-cuda serves another version of smsd, the first call
that looks for it issues one RuntimeWarning. The warning names both versions,
asks for smsd-cuda==8.1.0 and says that screening runs on the CPU; prepared
screens and pre-screens then stay on the CPU.
Prove that the GPU ran¶
gpu_is_available() and gpu_device_info() report what is available, not
what any call used. The only public evidence that work ran on the device is
FingerprintCorpus.cuda_calls: the number of packed CUDA calls, including
clustering passes, that the corpus has completed. Read it before and after a
call, as the packed-index example does; an increase proves that the device
ran. It is not a count of queries, hits or kernels, and it says nothing about
speed.
Prepared screens and gpu_prefilter=True searches have no public counter and
do not change cuda_calls. Their results are identical on either device, so
compare results rather than devices.
Time and compare CPU and GPU¶
Timings depend on the device, the processor, the corpus, the thread count and the load on the machine. Measure on the hardware and data that matter, and treat any figure as specific to that set-up. A fair comparison:
- calls
smsd_cuda.preload()first, so that device start-up is not timed; - prepares the corpus and runs each call once before timing, so that the first transfer to the device is not timed;
- times with
time.perf_counter()and keeps the best of several runs; - asserts that the GPU results equal the CPU results.
The harness below writes its timings to standard error and prints only
results that agree on every machine. It holds the prepared screen on the CPU
with SMSD_DISABLE_GPU, then allows the GPU for the 2,000-target corpus with
SMSD_CUDA_MIN_TARGETS.
import os
import sys
import time
from pathlib import Path
from tempfile import TemporaryDirectory
import smsd
from smsd.corpus import FingerprintCorpus
try:
import smsd_cuda
except ImportError:
smsd_cuda = None
def cuda_status():
"""Return whether backend="cuda" can be used here, and the reason when it cannot."""
if os.environ.get("SMSD_DISABLE_GPU", "") not in ("", "0"):
return False, "disabled by SMSD_DISABLE_GPU"
if smsd_cuda is None:
return False, "smsd-cuda is not installed"
if smsd_cuda.__version__ != smsd.__version__:
return False, (f"smsd-cuda {smsd_cuda.__version__} does not match "
f"smsd {smsd.__version__}")
reason = smsd_cuda.unavailable_reason()
return reason is None, reason
def timed(label, work, repeats=3):
"""Run once to warm up, then write the best of several timed runs to stderr."""
result = work()
best = float("inf")
for _ in range(repeats):
started = time.perf_counter()
assert work() == result
best = min(best, time.perf_counter() - started)
print(f"{label}: {best:.4f} s", file=sys.stderr)
return result
# Substituted rings with chains of 0 to 49 carbons: 5 x 8 x 50 = 2,000 molecules.
rings = ["c1ccccc1", "c1ccncc1", "C1CCCCC1", "c1ccoc1", "c1ccsc1"]
groups = ["O", "N", "Cl", "C(=O)O", "OC", "C#N", "F", "CC"]
library = [ring + group + "C" * length
for ring in rings for group in groups for length in range(50)]
queries = ["c1ccccc1O", "c1ccncc1Cl", "C1CCCCC1C(=O)O"]
ready, reason = cuda_status()
if ready:
smsd_cuda.preload() # keep device start-up out of the timings
corpus = smsd.TargetCorpus.from_smiles(library)
corpus.prewarm()
os.environ["SMSD_DISABLE_GPU"] = "1"
cpu_hits = timed("prepared screen, CPU", lambda: corpus.screen_many(queries, 0.4))
del os.environ["SMSD_DISABLE_GPU"]
if ready:
os.environ["SMSD_CUDA_MIN_TARGETS"] = str(len(library))
gpu_hits = timed("prepared screen, GPU allowed",
lambda: corpus.screen_many(queries, 0.4))
assert gpu_hits == cpu_hits
counts = [len(found) for found in cpu_hits]
print("targets:", len(library), "| hits per query:", counts)
assert len(library) == 2000 and counts == [2, 9, 2]
with TemporaryDirectory() as directory:
path = Path(directory) / "library.fp"
with FingerprintCorpus.from_smiles(path, library) as packed:
probes = [packed.fingerprint(index) for index in (0, 450, 1600)]
cpu_rows = timed("packed scan, CPU", lambda: list(
packed.screen_many(probes, threshold=0.4, backend="cpu")))
if ready:
before = packed.cuda_calls
gpu_rows = timed("packed scan, GPU", lambda: list(
packed.screen_many(probes, threshold=0.4, backend="cuda")))
assert gpu_rows == cpu_rows and packed.cuda_calls > before
print("packed hits:", len(cpu_rows))
assert len(cpu_rows) == 12
The program prints:
A prepared screen gives no public record of the device it used, so the
harness labels its second timing "GPU allowed". The packed timing is backed by
the cuda_calls assertion.
Containers¶
The GPU image smsd:8.1.0-cuda12.8 (linux/amd64) contains smsd, the
matching smsd-cuda and the CUDA 12 runtime, with Python as its entry point.
It has no Java command-line tool, no CUDA toolkit and no NumPy. As shipped,
it supports prepared screens, warm_start, the stream functions and
match_corpus(..., gpu_prefilter=True); packed indexes and clustering need
NumPy added in an image built from it. The image needs the NVIDIA Container
Toolkit and a compatible host driver, including under WSL2 on Windows.
After loading the image archive from the release:
docker run --rm --gpus all smsd:8.1.0-cuda12.8 \
-c "import smsd; print(smsd.__version__); print(smsd.gpu_device_info())"
As elsewhere, the backend string shows availability, not use. Command line and containers describes the CPU images, and the installation guide covers loading and building every image.
Metal and C++¶
Python never screens on Metal, and the published wheels include neither
Metal nor native CUDA support. A Python source build with Metal or CUDA
switched on changes only what gpu_is_available() and gpu_device_info()
report. In C++, a program built from source can link the Metal library and
request Metal fingerprint screening explicitly; see
Optional Metal fingerprint screening.
Matching stays on the CPU there as well.
Options¶
| Name | Used by | Type | Default | Meaning |
|---|---|---|---|---|
gpu_prefilter |
Substructure.match_corpus, MCS.match_corpus |
bool |
False |
True asks an attached device to run the pre-screen; a value that is not a bool raises TypeError |
backend |
FingerprintCorpus.similarities, screen_many, threshold_pairs, top_k_pairs; butina, maxmin |
str |
"cpu" |
"cpu", "cuda" or "auto"; any other value raises ValueError |
num_threads |
TargetCorpus.prewarm, screen, screen_many; warm_start; the stream functions; match_corpus |
int |
0 |
CPU threads; 0 uses the OpenMP default |
threshold |
warm_start |
float |
0.0 |
Inclusive ECFP4 Tanimoto threshold; the default returns every target, so pass one |
| Function or attribute | Returns | Meaning |
|---|---|---|
smsd.gpu_is_available() |
bool |
Whether a GPU backend is available |
smsd.gpu_device_info() |
str |
A description of the backend, such as CPU: ... or GPU: ... |
smsd.warm_start(corpus, query, threshold=0.0, *, num_threads=0) |
list[int] |
Prepares the corpus when needed and screens one query |
FingerprintCorpus.cuda_calls |
int |
Packed CUDA calls completed by this corpus |
smsd_cuda.is_available() |
bool |
Whether a device of compute capability 7.5 or later is present |
smsd_cuda.unavailable_reason() |
str or None |
Why no device can be used, or None |
smsd_cuda.device_info() |
str |
Device name, compute capability and memory; raises RuntimeError without a usable device |
smsd_cuda.preload() |
None |
Initialises the device and loads the screening kernels, without uploading data or changing automatic use |
smsd_cuda.__version__ |
str |
Must equal smsd.__version__ |
| Limit | Value |
|---|---|
| Compute capability | 7.5 or later |
| Automatic device rule | A Tesla T4 and 100,000 targets, unless SMSD_CUDA_MIN_TARGETS is set |
| Packed fingerprint width on the GPU | 65,536 bits; wider indexes use the CPU under "auto" and raise ValueError under "cuda" |
Queries in one packed screen_many call |
32, on either device |
| Byte order for packed GPU scans | Little-endian host |
Environment variables¶
| Variable | Default | Effect |
|---|---|---|
SMSD_DISABLE_GPU |
Unset | Unset, empty or 0 allows the GPU. Any other value keeps every route on the CPU and makes backend="cuda" raise. Read at each call. |
SMSD_CUDA_MIN_TARGETS |
Unset | A positive whole number N, written in decimal digits only, lets automatic prepared screens use any supported device for corpora of N targets or more; on a Tesla T4 it replaces 100,000. Signs, spaces, exponents, 0 and values too large for 64 bits are ignored. Read at each screen. It does not affect gpu_prefilter or backend. |
OMP_NUM_THREADS |
Unset | The CPU threads used when num_threads is 0; when unset, OpenMP chooses. |
SMSD_CUDA_POOL and SMSD_CUDA_GRAPHS tune the device workspace; see
Retained workspace and graph replay.
Notes¶
- Graph matching never runs on the GPU. A GPU screens or pre-screens, and every reported match comes from a CPU search.
- Prepared screens need
prewarm()after construction and after every addition; without it,screenandscreen_manyreturn no hits rather than raising.warm_startprepares the corpus when needed. See Screening a library. - A prepared corpus keeps its fingerprints on the device between screens. Adding targets drops that copy, and the next screen transfers them again.
- Results do not depend on the device or the thread count, and thresholds are inclusive on both devices.
cuda_callscounts packed calls only; it stays at 0 for CPU and fallback work.- The automatic rule is narrow by default, so a small demonstration corpus
stays on the CPU unless
SMSD_CUDA_MIN_TARGETSis set.
See also¶
- Screening a library
- Packed fingerprint index
- Command line and containers
- Optional CUDA screening in the Python guide
- CPU and CUDA backends for packed corpora
- The smsd-cuda package
- GPU screening example and the CPU and GPU screening notebook
- Supported platforms