# In Google Colab, install SMSD Pro first; elsewhere this cell does nothing.
import importlib.util, subprocess, sys
if importlib.util.find_spec("smsd") is None:
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "smsd==8.1.0", "rdkit<2027"], check=True)
CPU and GPU screening¶
This notebook shows where SMSD 8.1.0 work runs. Graph matching, which covers
every substructure, SMARTS and MCS search, always runs on the CPU. With the
optional smsd-cuda package and an NVIDIA GPU of compute capability 7.5 or
later, prepared ECFP4 screens, corpus pre-screens and packed fingerprint scans
can run on the GPU, and their hits, scores and decisions are identical to the
CPU results. Every cell also runs on a machine without a GPU: the GPU steps
check for a usable device first. NumPy is optional and needed only for the
packed-index steps, which report when it is missing.
The notebook performs no downloads and writes files only to temporary directories. In local Jupyter, select a kernel with SMSD 8.1.0 or later installed. For Colab, copy the setup cell of feature_search.ipynb to the top of this notebook and run it first; it installs an uploaded SMSD core wheel. No cell needs the CUDA package.
import os
import re
import time
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import smsd
from smsd.clustering import butina, maxmin
from smsd.corpus import FingerprintCorpus, stream_screen_smiles
_release = re.match(r"(\d+)\.(\d+)\.(\d+)", smsd.__version__)
if _release is None or tuple(int(part) for part in _release.groups()) < (8, 1, 0):
raise RuntimeError(f"This notebook needs SMSD 8.1.0 or later; found {smsd.__version__}")
print("SMSD", smsd.__version__)
try:
import numpy
except ImportError:
numpy = None
print("NumPy is not installed; the packed-index steps are skipped.")
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 smsd {smsd.__version__}"
reason = smsd_cuda.unavailable_reason()
return reason is None, reason
@contextmanager
def environment(name, value):
"""Set an environment variable inside a with block, then restore its previous state."""
previous = os.environ.get(name)
os.environ[name] = value
try:
yield
finally:
if previous is None:
os.environ.pop(name, None)
else:
os.environ[name] = previous
ready, reason = cuda_status()
print("CUDA screening:", "ready" if ready else f"unavailable ({reason})")
SMSD 8.1.0 CUDA screening: unavailable (smsd-cuda is not installed)
# Both calls report what is available, not which device a later call uses.
print("GPU available:", smsd.gpu_is_available())
# The backend string starts with 'CPU:' on a CPU-only machine and names the OpenMP
# threading. With smsd-cuda attached it starts with 'GPU:', followed by the device
# name, compute capability and memory. SMSD_DISABLE_GPU appends '[GPU disabled]'.
print("Backend:", smsd.gpu_device_info())
if ready:
smsd_cuda.preload() # initialise the device and load its kernels before any timing
print("Device:", smsd_cuda.device_info())
GPU available: False Backend: CPU: OpenMP 5.1, 2 threads
# A generated library: 5 rings x 8 groups x chains of 0 to 49 carbons = 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"]
# Prepared ECFP4 screens choose their device for each call. By default the GPU is used
# only on a Tesla T4 for 100,000 targets or more, so this 2,000-target corpus stays on
# the CPU unless SMSD_CUDA_MIN_TARGETS lowers that size.
corpus = smsd.TargetCorpus.from_smiles(library)
corpus.prewarm()
hits = corpus.screen_many(queries, 0.4)
print("Targets:", len(corpus), "| hits per query:", [len(found) for found in hits])
# warm_start prepares the corpus it is given; the stream screens one chunk at a time.
assert smsd.warm_start(smsd.TargetCorpus.from_smiles(library), queries[0], 0.4) == hits[0]
assert list(stream_screen_smiles(queries[0], iter(library), 0.4)) == hits[0]
print("warm_start and stream_screen_smiles give the same hits")
if ready:
# Let any supported device take corpora of one target or more.
with environment("SMSD_CUDA_MIN_TARGETS", "1"):
assert corpus.screen_many(queries, 0.4) == hits
print("The hits with the GPU allowed equal the CPU hits")
Targets: 2000 | hits per query: [2, 9, 2]
warm_start and stream_screen_smiles give the same hits
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)
targets = 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]
# The pre-screen rules out, with a proof, targets that cannot contain the query or
# cannot share enough atoms with it; the remaining targets are searched on the CPU.
# gpu_prefilter=True asks a device to run the pre-screen and falls back to the CPU;
# the decisions are the same either way.
core = smsd.Substructure.find("O=C1CN=C(c2ccccc2)c2ccccc2N1") # benzodiazepinone core
on_cpu = core.match_corpus(targets, gpu_prefilter=False)
requested = core.match_corpus(targets, gpu_prefilter=True)
assert decisions(requested) == decisions(on_cpu)
print("Core found in:", ", ".join(names[item.target_index]
for item in on_cpu if item.threshold_reached))
print("Ruled out before matching:", ", ".join(names[item.target_index]
for item in on_cpu if item.result is None))
mcs = smsd.MCS.find(benzodiazepines["alprazolam"])
on_cpu = mcs.match_corpus(targets, 22, gpu_prefilter=False)
requested = mcs.match_corpus(targets, 22, gpu_prefilter=True)
assert decisions(requested) == decisions(on_cpu)
print("All 22 atoms of alprazolam shared:", ", ".join(
names[item.target_index] for item in on_cpu if item.threshold_reached))
print("Substructure and MCS decisions are identical with gpu_prefilter=True")
Core found in: diazepam, nordazepam, temazepam, oxazepam, lorazepam, clonazepam, nitrazepam, flunitrazepam, tetrazepam Ruled out before matching: alprazolam, triazolam, midazolam, clobazam All 22 atoms of alprazolam shared: alprazolam, triazolam Substructure and MCS decisions are identical with gpu_prefilter=True
if numpy is None:
print("NumPy is not installed; skipping the packed-index backends.")
else:
options = smsd.FingerprintOptions.ecfp(radius=2, size=2048)
query = smsd.fingerprint(benzodiazepines["diazepam"], options=options)
with TemporaryDirectory() as directory:
path = Path(directory) / "benzodiazepines.fp"
with FingerprintCorpus.from_smiles(path, list(benzodiazepines.values()),
options=options) as packed:
on_cpu = list(packed.similarities(query, threshold=0.4, backend="cpu"))
for index, score in on_cpu:
print(f"{names[index]:<11} {score:.3f}")
# "auto" uses a usable device for an index of any size, and the CPU otherwise.
assert list(packed.similarities(query, threshold=0.4, backend="auto")) == on_cpu
assert butina(packed, threshold=0.4, backend="auto") == \
butina(packed, threshold=0.4, backend="cpu")
assert maxmin(packed, count=3, backend="auto") == \
maxmin(packed, count=3, backend="cpu")
# cuda_calls counts completed packed CUDA calls; CPU work leaves it unchanged,
# so it is above 0 here only where the "auto" calls ran on a device.
print("CUDA calls after the cpu and auto scans:", packed.cuda_calls)
if ready:
before = packed.cuda_calls
on_gpu = list(packed.similarities(query, threshold=0.4, backend="cuda"))
assert on_gpu == on_cpu and packed.cuda_calls > before
print("backend='cuda' hits equal the CPU hits; CUDA calls:", packed.cuda_calls)
diazepam 1.000 nordazepam 0.528 temazepam 0.582 tetrazepam 0.542 clobazam 0.500 CUDA calls after the cpu and auto scans: 0
if numpy is None:
print("NumPy is not installed; skipping the packed-index errors.")
else:
with TemporaryDirectory() as directory:
with FingerprintCorpus.create(Path(directory) / "bits.fp", [[1], [1, 2], [3]],
fp_size=8) as small:
# Packed searches are generators: a failed backend="cuda" request raises
# when the first item is requested, not when the method is called.
pending = small.similarities([1], threshold=0.5, backend="cuda")
print("The call returns a", type(pending).__name__)
if ready:
cpu = list(small.similarities([1], threshold=0.5, backend="cpu"))
assert list(pending) == cpu
print("backend='cuda' ran on the device with the CPU hits")
else:
try:
next(pending)
except RuntimeError as error:
print("First item:", error)
else:
raise AssertionError("backend='cuda' should fail without a usable device")
# Wider than 65,536 bits: "auto" stays on the CPU and "cuda" is refused.
with FingerprintCorpus.create(Path(directory) / "wide.fp", [[1], [70000]],
fp_size=70001) as wide:
print("auto, 70,001 bits:",
list(wide.similarities([70000], threshold=0.5, backend="auto")))
try:
list(wide.similarities([70000], threshold=0.5, backend="cuda"))
except ValueError as error:
print("cuda, 70,001 bits:", error)
else:
raise AssertionError("backend='cuda' should refuse 70,001 bits")
The call returns a generator First item: The requested CUDA packed-screening backend is unavailable: No module named 'smsd_cuda' auto, 70,001 bits: [(1, 1.0)] cuda, 70,001 bits: CUDA packed screening supports at most 65536 bits
# SMSD_DISABLE_GPU is read at each call, so it can be set and removed while running.
with environment("SMSD_DISABLE_GPU", "1"):
print("GPU available:", smsd.gpu_is_available())
print("Backend:", smsd.gpu_device_info())
assert smsd.gpu_device_info().endswith("[GPU disabled]")
assert corpus.screen_many(queries, 0.4) == hits # prepared screens use the CPU
if numpy is not None:
with TemporaryDirectory() as directory:
with FingerprintCorpus.create(Path(directory) / "bits.fp",
[[1], [1, 2], [3]], fp_size=8) as small:
automatic = list(small.similarities([1], threshold=0.5, backend="auto"))
print("auto with the GPU disabled:", automatic,
"| CUDA calls:", small.cuda_calls)
assert small.cuda_calls == 0
print("Backend after restoring the variable:", smsd.gpu_device_info())
GPU available: False Backend: CPU: OpenMP 5.1, 2 threads [GPU disabled] auto with the GPU disabled: [(0, 1.0), (1, 0.5)] | CUDA calls: 0 Backend after restoring the variable: CPU: OpenMP 5.1, 2 threads
Time CPU and GPU work¶
The next cell times the same screens on the CPU and, where a device is ready,
on the GPU. In that case an earlier cell has already initialised the device
with smsd_cuda.preload(). Each call runs once before it is measured, the
best of three runs measured with time.perf_counter() is kept, and the GPU
results are asserted equal to the CPU results. The prepared screen is held on
the CPU with SMSD_DISABLE_GPU and then allowed on the GPU with
SMSD_CUDA_MIN_TARGETS; only the packed scan proves device use, through
cuda_calls.
Timings depend on the device, the processor, the corpus, the thread count and the load on the machine. They describe this run only and are not benchmarks. Without a usable device, only the CPU lines appear.
def timed(label, work, repeats=3):
"""Run once to warm up, then print the best of several timed runs."""
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:<30} {best:8.4f} s")
return result
with environment("SMSD_DISABLE_GPU", "1"):
cpu_hits = timed("prepared screen, CPU", lambda: corpus.screen_many(queries, 0.4))
if ready:
with environment("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
print("Hits per query:", [len(found) for found in cpu_hits])
if numpy is None:
print("NumPy is not installed; skipping the packed-scan timing.")
else:
with TemporaryDirectory() as directory:
with FingerprintCorpus.from_smiles(Path(directory) / "library.fp", 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))
prepared screen, CPU 0.0002 s Hits per query: [2, 9, 2] packed scan, CPU 0.0005 s Packed hits: 12
Summary¶
Every route that ran gave the same hits, scores and decisions as the CPU. With
a usable device, the packed scans also proved device use through
cuda_calls; prepared screens and pre-screens keep no public record of the
device they used. The
CPU and GPU screening chapter covers
installation, the fallback rules and the environment variables, and
gpu_screening.py shows the same pattern as a script.