GPU Exercises: Solutions
Worked answers to every open-ended question in GPU Exercises 1 to 4.
Go Back to Splash »Contents
How to use this page
Most of the questions in these exercises don't have one fixed answer - they ask you to read your own job output and explain what you see. For those, this page gives the reasoning and the pattern to look for, not a number to copy, because your number depends on which GPU you were given and what you ran. Questions that do have one correct answer (fix this line, what code do you add) are given in full.
Section headings below match the exercise they answer, so you can jump to the same point in each.
Exercise 1: your first GPU job
From GPU Exercises 1.
Write the job script
-
Which line asks Slurm for a GPU? What would happen if you deleted it?
#SBATCH --gres=gpu:<GPU_FLAVOR>:1(on Brighton,#SBATCH --gres=gpu:RTX:1). Delete it and the job still runs, but as an ordinary CPU allocation with no GPU attached -nvidia-smireports something likeNo devices were found, because Slurm never gave the job a GPU to see. -
What does
gpu:1(no flavour) mean? When would you name the flavour?gpu:1means "any one GPU in this partition, whichever flavour it is." Name the flavour when your code depends on the CUDA version or GPU architecture - compiled CUDA code, a specific CUDA toolkit, or a framework build that only contains code for one architecture. If your code just needs "a GPU" and doesn't care which,gpu:1is enough. -
Why is
--timeset to only 5 minutes? The script only printsnvidia-smioutput, so it finishes in seconds. A short, realistic--timestarts sooner (Slurm can fit it into a gap in the schedule) and is a courtesy on a workshop partition with only 6 GPUs shared by the room.
Read the results
-
Which compute node did the job run on? Read it from the first line of the output -
echo "Job $SLURM_JOB_ID on $(hostname) at $(date)"prints the hostname directly. -
Which GPU model did you get, and how much memory does it have? Read the
Nameandmemory.totalfields from thenvidia-smioutput. This is genuinely per-allocation - write down what your own job reports, since you'll need that memory figure again in Exercise 2 (question 5, below). -
What is the GPU's compute capability? On the
brightonpartition, every node is RTX, so you should see 12.0 (sm_120) - a newer architecture than the A40's 8.6. That's why Exercise 2 and Exercise 3 both make a point of matching your CUDA build and compile target to the architecture you were given. -
What CUDA version does
nvidia-smireport in its header? What does that number mean? This is the newest CUDA version the GPU's driver supports - a ceiling, not what's actually loaded in your shell. On Artemis this should be 12.9 or newer, which is why theCUDA/12.9.1module (new enough forsm_120) works here. -
What was the GPU doing when you ran
nvidia-smi? Idle: 0%GPU-Utiland close to 0 MiB used. The script never launches any GPU work, so there's nothing there to see yet - this is the expected, correct answer, not a sign anything went wrong. -
Add two lines that print the working directory and the number of CPUs (
$SLURM_CPUS_ON_NODE):echo "Working directory: $(pwd)" echo "CPUs on this node: $SLURM_CPUS_ON_NODE"
Job Composer: repair the slurm_gpu template
The exact mistakes depend on the template's current content, but the checklist is always the same three things: the partition must be brighton, the --gres line must use the gpu:<flavour>:<number> syntax (not an older or malformed form), and it should ask for exactly one GPU (gpu:RTX:1), not more. If the fixed job still fails, compare it line-by-line against gpu_test.job from earlier in this exercise - that script is a known-working reference.
Extension: what happens without a GPU request?
- What does
nvidia-smisay now? The same as if you'd never asked for a GPU at all: no device is visible (e.g.No devices were found), becauseCUDA_VISIBLE_DEVICESis empty for the job. - Why does this matter for your own code? Many frameworks silently fall back to the CPU instead of erroring when no GPU is visible. A job can "work" - just slowly, and without you noticing - if you don't explicitly check and log whether a GPU was found (as
gpu_check.pydoes in Exercise 2).
Extension: gpu:1 or a named flavour?
- Why might the GPU name not match, on a partition with more than one flavour?
gpu:1picks any available GPU in the partition. On a partition that mixes A40 and RTX nodes, twogpu:1jobs submitted at different times could land on different node types and come back with different GPU names, different memory, and different compute capabilities. Onbrightonevery node is RTX, sogpu:1andgpu:RTX:1always agree - but naming the flavour is what makes that guaranteed rather than incidental.
Exercise 2: Python on the GPU
From GPU Exercises 2.
Understand the results
-
Was CUDA available? Which GPU did PyTorch find? Did
Build supportsinclude your architecture? With a CUDA-enabled build matching your GPU,CUDA availableshould beTrue, the GPU name should match Exercise 1, andsm_120should appear in theBuild supportslist. AWARNINGmeans the installed build doesn't contain code forsm_120- go back and install a newer build (Option B, step 3). -
How much faster was the GPU than the CPU? Expect a large speed-up - commonly somewhere in the tens-of-times range for an 8192×8192 matrix multiply, though the exact figure depends on your GPU and the CPU it's compared against. The reasoning matters more than the number: dense matrix multiplication is highly parallel and exactly the kind of workload a GPU is built for, so a big speed-up here is the expected, correct result.
-
When did GPU utilisation rise, and what was it doing the rest of the time? Utilisation should spike during the timed
matmulrepeats and sit near 0% otherwise - while Python builds the random matrices, copies them to the GPU, and handles other overhead. A GPU that's busy only in short bursts, with idle time either side, is normal for a script this small. -
Change
--nto 512, 2048, 16384: how does the speed-up change, and why do small problems benefit less? Speed-up should grow as--ngrows. Atn=512there's very little arithmetic to do, so the fixed costs - launching the kernel, copying data to and from the GPU - can dominate the total time, and the GPU may barely beat the CPU or even lose to it. Atn=16384there's far more arithmetic per byte moved, so that fixed overhead becomes a small fraction of the total and the GPU's raw throughput advantage shows through. -
Roughly how large can
nbe beforeCUDA out of memory? Work it out from your GPU's memory (from Exercise 1) rather than guessing: the script holds threen×nfloat32 matrices on the GPU (the two inputs and the result), and each float32 element is 4 bytes, so it needs roughly3 × n² × 4bytes, plus some working memory PyTorch reserves on top. Solve3 × n² × 4 < (your GPU's free memory in bytes)fornas a rough ceiling - real headroom will be a bit lower once PyTorch's own overhead is included.
Exercise 3: CUDA modules and compiling
From GPU Exercises 3.
Find and understand CUDA modules
-
Split
NCCL/2.27.7-GCCcore-14.3.0-CUDA-12.9.1into software / version / toolchain / CUDA version: Software =NCCL, version =2.27.7, toolchain =GCCcore-14.3.0, CUDA version =12.9.1. -
Do the same for two other modules from
module avail CUDA: The names on your own system will differ, but the pattern is always<Software>/<version>-<toolchain>-<suffix>, and any-CUDA-<version>segment in the suffix is the CUDA version that module was built against. Read each one left to right in the same four pieces as the NCCL example above. -
What warning does Module give when you load a second, conflicting CUDA version? Something in the family of
Lmod is automatically replacing "CUDA/12.9.1" with "CUDA/<other version>", or a note that other modules were reloaded with a version change. It's Lmod telling you it swapped out part of your environment to satisfy the new module - exactly the silent, order-dependent surprise thatmodule purgebefore loading a fresh set avoids.
Compile and run CUDA C
-
Did it print
PASSED? What GPU did it use, and what bandwidth did it achieve?PASSEDis expected: the program checks its own answer (every element should equal 3.0) and only printsFAILEDif something is wrong. The GPU name matches your allocation from Exercise 1. There's no single "correct" bandwidth figure, but as a sanity check it should be a sizeable fraction of your GPU's rated memory bandwidth - a number close to zero would suggest something is misconfigured (for example, running on the wrong device) rather than a genuine result. -
Does the speed change when you target
-arch=sm_<yours>explicitly? Often little or no visible change for this program.vector_addis memory-bandwidth-bound, not compute-bound - it does one addition per element it reads, so its speed is limited by how fast data moves, not by arithmetic throughput, and that doesn't change much with the compile target. What naming the architecture does guarantee is that the binary contains code for your GPU at all, rather than relying on the toolkit's default and (depending on the toolkit) a slower first-run JIT step or an outright failure to run on a newer architecture. -
Set
threads = 2048invector_add.cu: what error do you get? A kernel launch failure, typically reported as something likeinvalid configuration argument. Almost all current NVIDIA GPUs cap threads per block at 1024, so requesting 2048 is invalid before the kernel even starts.CHECK(cudaGetLastError())is what surfaces this - without it, the launch would fail silently and the program would reportFAILED(or hang) with no explanation.
Benchmark: mixbench
- Where does the GPU stop being memory-bound and start being compute-bound? Look for the point where the
GB/scurve flattens out (memory bandwidth is maxed) whileGFLOPSkeeps climbing as more arithmetic is done per byte moved - that crossover is the "ridge point" in roofline terms. Below it, performance is capped by how fast data can move (GB/s stays roughly flat, GFLOPS rises with more work per byte); above it, performance is capped by arithmetic throughput (GFLOPS plateaus near the GPU's peak, GB/s falls off as less new data is needed per calculation). The exact crossover point is specific to your GPU, but the shape of the curve is the thing to look for.
Exercise 4: your own code
From GPU Exercises 4.
Exercise 4 is intentionally open (your own code), so most of it has no single answer. The exceptions:
Scale up with a job array
- In
squeue --me, how are the array tasks shown? As one entry per task in the form<jobid>_<taskindex>(for example12345_1,12345_2, ...) rather than as separate, unrelated job IDs. Tasks beyond your%concurrency limit show as pending until a running task finishes and frees a slot.
No code of your own? (extension prompts)
-
Add
torch.sinalongside the matrix multiply: which benefits more from the GPU, and why? Matrix multiply benefits far more. It's compute-bound and highly reused - for ann×nmultiply there areO(n³)operations overO(n²)data, and libraries like cuBLAS are heavily optimised for exactly this shape of work.torch.sinis elementwise:O(n²)operations overO(n²)data, one operation per element read, so it's memory-bandwidth-bound in the same wayvector_add.cuis. It will still be faster on the GPU than the CPU, just by a much smaller margin than the matrix multiply. -
Time the memory copies in
vector_add.cuas well as the kernel: how much of the total time is data movement? For a kernel this simple, expect the host-to-device and device-to-host copies to account for a large share of the total time - often more than the kernel itself, especially at smallern. This is the general lesson behind "prepare on CPU, run on GPU" from the workshop overview: for small, simple kernels, moving data is frequently the expensive part, not the computation.