This tutorial introduces the computing resources available on the
Druta server and demonstrates, using one mathematical problem, how the
same computation can be implemented using a serial CPU, a multicore CPU
with OpenMP, and an NVIDIA GPU with CUDA.
The goal is not simply to show that one program can be made
faster.
The more important goal is to understand how to look at a
mathematical computation and recognize opportunities to use the
resources available on Druta.
A computational problem may begin as a simple mathematical expression
but become computationally expensive when it has to be evaluated
millions or billions of times.
“How can my mathematical computation be expressed so that the
available hardware can execute it efficiently?”
Druta provides several levels of computational capability:
DRUTA
|
+--------------+--------------+
| |
CPU GPU
| |
+------+------+ CUDA
| | parallelism
Serial OpenMP |
| | |
one CPU many CPU many GPU
execution threads threads
The same mathematical problem may therefore have several possible
implementations.
This tutorial uses the Mandelbrot set as a simple
example because:
it is mathematically interesting;
it's computation can be visualized immediately;
each pixel can be calculated independently, making it a natural
example of parallel computing.
For a programmer, the most relevant hardware features are the ones
that affect how a program can execute.
2.1 CPU
Druta contains:
2 CPU sockets
12 physical cores per socket
24 physical CPU cores in total
48 logical processors through simultaneous
multithreading
The distinction between physical and logical processors is
important.
Therefore, saying that Druta can run 48 OpenMP
threads does not mean that it has 48 physical CPU cores. It has
24 physical cores and 48 logical processors.
For a programmer, the CPU provides several possibilities:
Programming style
Typical use
Serial C/C++
One CPU execution stream
OpenMP
Multiple CPU threads
Multiple processes
Independent calculations
Vectorized CPU code
SIMD operations on several data elements
This tutorial demonstrates the first two.
2.2 Main Memory
Druta has approximately 256 GB of RAM.
Large RAM is particularly useful for mathematical computing
involving:
large matrices;
large simulation grids;
image stacks;
numerical datasets;
finite-difference calculations;
statistical computations;
intermediate arrays.
Having a large amount of RAM does not automatically make a program
fast.
A program may instead be limited by:
CPU computation;
memory bandwidth;
storage;
CPU-to-GPU data transfers;
algorithmic dependencies.
The first step is therefore to understand what the computation
actually needs.
2.3 GPU
Druta has an:
NVIDIA RTX 4500 Ada Generation
The CUDA experiment reports:
60 streaming multiprocessors (SMs);
approximately 24 GB of GPU memory;
CUDA execution using a 16 × 16 thread block.
A useful mental model is:
CPU
|
+-- relatively small number of powerful CPU cores
|
+-- good for general-purpose work and complex control flow
GPU
|
+-- many parallel execution units
|
+-- excellent for large numbers of similar calculations
The GPU does not automatically accelerate every
program.
The algorithm must contain suitable parallelism, and the cost of
moving data between CPU memory and GPU memory may also matter.
The Mandelbrot set is defined using the iteration
where
and initially
For every point
,
we repeatedly calculate
and so on.
A point is considered to have escaped when
In an actual computer program, we stop when either:
the magnitude exceeds 2; or
a specified maximum number of iterations is reached.
The exact Mandelbrot set is defined by whether the sequence remains
bounded. In a computer visualization, however, we perform only a finite
number of iterations. The value of maxIter therefore
determines the numerical approximation used to colour the image.
The number of iterations required before escape is used to determine
the corresponding pixel value.
3.1 From Mathematics to Pixels
Suppose we want an image of width
and height
.
Each pixel corresponds to one point
Conceptually:
for every pixel (px, py):
convert pixel position to c = x + iy
z = 0
for iteration = 1 ... maxIterations:
z = z*z + c
if |z| > 2:
break
store iteration count
The important observation is:
The calculation for one pixel does not normally depend on the
calculation for another pixel.
This structure can be exploited by parallel computing.
For example, consider a
image.
The number of pixels is
With a maximum of 5000 iterations, the program may perform a very
large number of arithmetic operations.
The mathematical problem has not changed. We can execute the same
calculation in different ways:
Mandelbrot calculation
|
+----------------+----------------+
| | |
Serial OpenMP CUDA
| | |
one CPU CPU threads GPU threads
stream
This is a clean demonstration of algorithmic
parallelism.
The key idea can be written as:
Pixel (px, py)
|
v
c = x + iy
|
v
z0 = 0
|
v
z(n+1) = z(n)^2 + c
|
v
zx, zy determine whether the point escapes
|
v
iteration count
|
v
image[py, px]
The calculation for one pixel is independent of the calculation for
another pixel.
Therefore, the outer pixel calculation can be divided among different
execution units.
Serial
pixel 1 -> pixel 2 -> pixel 3 -> pixel 4 -> ...
OpenMP
CPU
|
+-- thread 1 -> some rows
+-- thread 2 -> some rows
+-- thread 3 -> some rows
...
This is not simply “OpenMP but faster.” CUDA requires a different
execution model and explicit consideration of GPU-specific concepts such
as kernels, blocks, grids, device memory, and CPU-GPU data
transfers.
For Mandelbrot, however, the programming model matches the structure
of the problem very well.
8.1 CUDA Blocks, Grids and the
Kernel
CUDA groups threads into blocks.
The experiment uses:
threads per block.
For a
image, this corresponds to:
blocks.
For a
image:
blocks.
The central programming concept is a kernel.
A CUDA kernel is a function executed by many GPU threads.
Conceptually:
__global__ void mandelbrot_kernel(...){int pixel_x =...;int pixel_y =...;// Calculate Mandelbrot for this pixel}
Each CUDA thread determines which pixel it is responsible for.
Thus:
CUDA thread
|
+----> one pixel
This is an extremely useful pattern in scientific computing.
If a problem can be expressed as:
for every data element:
perform essentially the same calculation
We use NVIDIA’s CUDA compiler, nvcc, to compile the CUDA
program.
Compile with:
nvcc-O3 mandelbrot_cuda.cu -o mandelbrot_cuda
Run a small test:
./mandelbrot_cuda 1000 1000 5000
For the largest test:
./mandelbrot_cuda 12000 12000 5000
The observed CUDA timings are:
Resolution
Iterations
GPU kernel time
1000 × 1000
5000
0.0332037 s
2000 × 2000
5000
0.117723 s
4000 × 4000
5000
0.429430 s
8000 × 8000
5000
1.68183 s
12000 × 12000
5000
3.75710 s
Important timing note
The CUDA program reports GPU kernel execution
time.
It should therefore not automatically be interpreted as
end-to-end application time.
The reported GPU timing does not represent the complete cost of:
CPU-side memory allocation;
GPU memory allocation;
CPU-to-GPU data transfers;
GPU-to-CPU data transfers;
writing the output file.
For a production application, these costs may be important.
Therefore, throughout this tutorial, the CUDA measurements should be
understood as GPU kernel execution times.
The most useful comparison is to run exactly the same mathematical
workload using:
serial CPU;
OpenMP CPU;
CUDA GPU.
For the
,
5000-iteration calculation:
Implementation
Resources
Measured time
Serial
One CPU execution stream
283.402 s
OpenMP
48 CPU threads
18.7888 s
CUDA
RTX 4500 Ada GPU kernel
3.75710 s*
* GPU kernel execution time reported by the CUDA program.
The measurements show a substantial reduction in measured computation
time when the available parallel hardware is used.
However, the three timing measurements are not identical in
scope:
the serial value is the measured CPU execution time;
the OpenMP value is the measured CPU execution time using 48
threads;
the CUDA value is the measured GPU kernel
time.
This distinction is important when interpreting speedup.
9.1 What Does Speedup Mean?
Speedup is commonly defined as
For the OpenMP experiment:
Thus, in this experiment, the 48-thread OpenMP implementation takes
about 15 times less measured computation time than the serial
implementation.
For the CUDA kernel:
This is a benchmark ratio between the serial CPU time and the
reported GPU kernel time.
It should not be described as a general 75× application speedup
because the CUDA timing excludes the other parts of the end-to-end
workflow.
These numbers are measured benchmark ratios, not
hardware specifications.
They depend on:
the particular implementation;
the workload;
compiler settings;
OpenMP thread count;
CPU architecture;
GPU architecture;
CUDA kernel design;
numerical precision;
timing method.
9.2 Complete Benchmark Table
The complete measurements are:
Resolution
Serial (s)
OpenMP, 48 threads (s)
CUDA GPU kernel (s)
1000 × 1000
2.09451
0.173036
0.0332037
2000 × 2000
7.90573
0.757527
0.117723
4000 × 4000
31.4925
2.80149
0.429430
8000 × 8000
125.996
8.23423
1.68183
12000 × 12000
283.402
18.7888
3.75710
One useful way to summarize the largest calculation is:
A calculation that takes nearly five minutes in the serial
implementation takes about 19 seconds using 48 OpenMP
threads and about 3.8 seconds of measured GPU kernel
time in this experiment.
A run with a specified image dimension and iteration count produces a
pixel-by-pixel binary file.
The visualization script extracts a small portion of the calculated
image so that the effect of increasing resolution can be observed.
The visualization uses the fixed mathematical region
and
This is important.
If the mathematical coordinate range changes between images,
comparisons can be misleading.
Keeping the same coordinate range means that increasing the
resolution means adding more samples to the same mathematical
region.
For example, the following images show the same zoom region at
increasing resolutions.
1000 × 1000
2000 × 2000
4000 × 4000
8000 × 8000
12000 × 12000
As the resolution increases, more pixels are used to represent the
same mathematical region.
10.1
Why Should the Serial, OpenMP, and CUDA Images Look the Same?
The purpose of parallelization is to change how the
computation is executed, not what mathematical problem is being
solved.
Ideally:
SAME MATHEMATICS
|
+--------------+--------------+
| | |
Serial OpenMP CUDA
| | |
+--------------+--------------+
|
v
SAME RESULT
For the same:
mathematical region;
image resolution;
maximum iteration count;
Mandelbrot algorithm;
the three implementations should produce visually equivalent
images.
The main difference should be how long the calculation
takes.
Small numerical differences can occur near fractal boundaries because
different processors and compilers may evaluate floating-point
operations slightly differently. For this reason, “same result” should
be understood as mathematically and visually equivalent rather than
necessarily bit-for-bit identical.
The Mandelbrot calculation can be expressed as:
for each pixel:
calculate pixel independently
This can be transformed into:
Serial
pixel 1 -> pixel 2 -> pixel 3 -> pixel 4 -> ...
OpenMP
thread 1 -> some pixels
thread 2 -> some pixels
thread 3 -> some pixels
...
“Where is the independent work in my mathematical
algorithm?”
12.1 OpenMP
OpenMP is often the easiest first step for an existing C/C++
program.
A suitable loop can frequently be parallelized with a directive such
as:
#pragma omp parallel for
The program continues to use ordinary CPU memory.
Advantages
relatively small programming change;
uses existing CPU cores;
good for large CPU-based workloads;
suitable for many loops;
easy to experiment with different thread counts.
Questions to consider
Are the loop iterations independent?
Are there race conditions?
Is shared memory being accessed safely?
How many threads should be used?
Is the computation CPU-bound or memory-bound?
Does performance continue to improve as more threads are added?
12.2 CUDA
CUDA requires a different execution model.
The programmer explicitly designs:
GPU kernels;
thread indexing;
blocks;
grids;
device memory;
CPU-GPU data transfers.
Advantages
very large numbers of parallel threads;
excellent for suitable data-parallel algorithms;
particularly useful when the same operation is repeated over very
large datasets.
Questions to consider
Is the algorithm sufficiently parallel?
How much data must be transferred to the GPU?
Can the GPU remain busy?
Is the calculation limited by GPU computation or memory
bandwidth?
What numerical precision is required?
Is the extra programming complexity justified?
12.3 Which Approach Should I
Use?
There is no universal rule that says every computation should use
CUDA.
A useful first decision guide is:
Problem characteristic
A reasonable first approach
Small computation
Serial CPU
Complex or highly irregular control flow
CPU
Independent loops in existing C/C++ code
OpenMP
Large number of independent similar calculations
CUDA
Large matrices
BLAS/LAPACK or other optimized libraries
MATLAB numerical workloads
MATLAB CPU/GPU facilities, depending on the algorithm
Many independent simulations
OpenMP or CUDA
Strong sequential dependencies
CPU-oriented approach
Frequent CPU-GPU data transfers
Carefully evaluate whether CUDA is worthwhile
This is only a starting point.
The best implementation depends on the structure of the algorithm,
the amount of data, memory access patterns, numerical requirements, and
the cost of moving data between different parts of the system.
The experiment also demonstrates an important practical issue.
Going from
to
does not mean that the problem became 12 times
larger.
It became
times larger in terms of pixel count.
This is why high-resolution scientific calculations can become
expensive very quickly.
Parallel computing makes it possible to explore larger resolutions
and more computationally demanding parameter spaces within a practical
time.
However, parallelism does not remove the computational cost. It
changes how the available hardware is used to perform that work.
The Mandelbrot example suggests a general workflow.
Mathematical problem
|
v
Write a simple
serial program
|
v
Verify the result
|
v
Identify independent work
|
v
Try OpenMP
|
v
Measure speedup
|
v
Is the problem massively
data-parallel?
/ \
Yes No
| |
v v
CUDA Continue with CPU
|
v
Measure again
A good principle is:
First make the mathematics correct. Then make the
implementation parallel. Then measure.
This is preferable to starting with CUDA immediately.
A correct serial implementation is also extremely useful as a
reference against which parallel implementations can be checked.
14.1 What to Look for in Your
Own Code
When examining a research program, look for loops that perform the
same calculation many times.
For example:
for(int i =0; i < N; i++){ result[i]= expensive_function(data[i]);}
Ask:
Is result[i] independent of
result[j]?
Does each iteration perform a similar amount of work?
Is the number of iterations large?
Does each iteration access memory efficiently?
Is the computation dominated by arithmetic or by memory access?
Is there significant communication between iterations?
Does the calculation need to move data between CPU and GPU?
If the iterations are independent and there are many of them, OpenMP
may be a good first experiment.
If there are very many similar independent
calculations, CUDA may be worth considering.
If the calculation contains strong dependencies between iterations,
forcing it into a massively parallel model may not help.
The Mandelbrot calculation is deliberately simple, but the
programming ideas are broadly useful.
Lesson 1 — Hardware matters
Druta provides:
24 physical CPU cores;
48 logical processors;
approximately 256 GB of RAM;
an NVIDIA RTX 4500 Ada GPU with approximately 24 GB of GPU
memory.
The resources available on the machine determine what types of
computation can be explored efficiently.
Lesson 2 — Algorithms matter
The same mathematical problem can have very different execution
characteristics depending on how it is programmed.
Lesson 3 — Parallelism
matters
The Mandelbrot calculation contains a large amount of independent
work.
That makes it suitable for:
CPU multithreading with OpenMP;
GPU parallelism with CUDA.
Lesson 4 — Measurement
matters
Performance should be measured rather than assumed.
The fact that a machine has many cores or a powerful GPU does not by
itself tell us how much faster a particular program will run.
Lesson 5 — Visualization
helps
The Mandelbrot image provides an immediate way to check that the
calculation is producing a meaningful result.
It also makes the effect of increasing resolution easy to see.
Lesson 6 — Scaling matters
Increasing resolution increases the number of calculations very
rapidly.
For an
image, the number of pixels is proportional to
.
Lesson 7 — Match the
problem to the hardware
The important idea is not that CUDA is always faster
than OpenMP.
It is also not true that more CPU threads automatically make a
program faster.
The important idea is:
Match the computational structure of the mathematical problem
to the architecture of the machine.
The overall approach can be summarized as:
Mathematical problem
|
v
Understand the
computation
|
v
Find independent
work
|
+------------+------------+
| |
v v
CPU parallelism GPU parallelism
| |
v v
OpenMP CUDA
| |
+------------+------------+
|
v
Benchmark
|
v
Analyse result
|
v
Choose the approach
appropriate for the
real problem
The most useful skill to take away from this tutorial is therefore
not specific knowledge of Mandelbrot, OpenMP, or CUDA.
It is the ability to look at a mathematical computation and ask:
Where is the independent work, and which part of Druta’s
hardware is best suited to execute it?
That is the starting point for making effective use of a
high-performance computing system.
Note for TensorFlow users: If you plan to use TensorFlow, create an environment with Python 3.13, as
TensorFlow compatibility depends on the Python version. Newer Python versions may not yet be supported by the
TensorFlow release you intend to install.
After creating the environment, activate it using:
user@druta:~$ conda activate env_name
You may be prompted to log out of your shell after activating the environment. Please log out and log in to the
server again.
Once the environment is activated, you will see the environment name at the beginning of your shell prompt:
(env_name) user@druta:~$
To check the Python version installed in your Conda environment, execute:
(env_name) user@druta:~$ python --version
You may need internet connection for installing a package. Open Firefox or Chrome browser for Internet
connection:
You can install Python packages using pip or conda within your Conda environment.
Note: Make sure your Conda environment is activated before installing packages. The active environment
name
should appear at the beginning of your shell prompt.
Replace package_name, package1, package2, and package3 with
the
names of the packages you require.
Important: Use python -m pip rather than simply pip to ensure that packages are
installed using the Python interpreter associated with your active Conda environment.