diff --git a/00introCS/basicTypes.md b/00introCS/basicTypes.md new file mode 100644 index 000000000..90bac6b55 --- /dev/null +++ b/00introCS/basicTypes.md @@ -0,0 +1,65 @@ +--- +title: Basic Types +--- + +# Basic Types in Programming Languages + +In order to effectively work in any programming language, we need a basic understanding of a number of fundamental types. We will give these types by the names in C++, although there are generally analogues in other languages. + +## Boolean Types + +Boolean types represent true/false values. In C++ the type is called `bool` and it takes 1 byte of memory. + +## Integer Types + +All programming languages will have some types to describe integer values. We use these to represent things that in mathematics are in the set of _natural numbers_ $\mathbb{N}$ or _integers_ $\mathbb{Z}$. In C++ we might most commonly use: + +- `int`: integers that can be positive or negative, and take 4 bytes of memory. +- `unsigned int`: integers that can only be positive, and take 4 bytes of memory. Because they don't have to represent negative numbers as well, `unsigned int` can represent larger values than `int`. +- `std::size_t`: unsigned integers that can represent that size of the largest possible object; this is usually determined by the addressing on an architecture. For example, on a 64-bit system `size_t` will typically be 8 bytes. This is the standard type to use for things like indexing arrays/vectors since it can't be negative and is guaranteed to be able to represent the largest index of an element of an array. + +There are many other types that C++ offers, although not all of them are standard. You should use the C++ documentation to check whether a type that you want to use is standardised and portable across different architectures. (As an example, I used to use `uint` for unsigned integers until some Mac users informed me that it doesn't compile with the `clang` compiler!) + +## Strings and Characters + +A single character can be represented using `char`, which takes a single byte. For strings you are advised to use the C++ type `std::string`. In some places you will come across `char*`, an C-style array of `char`, as an alternative to C++ strings. These should only be used where necessary for compatibility. The most common place for this to come up is command line arguments for the `main` function. + +## Floating Point Representation of Real Numbers + +The real numbers, $\mathbb{R}$, is the set of numbers along the number line. There are infinitely many real numbers in any finite interval, and this poses a problem for computation: we clearly cannot represent all the real numbers exactly, since we have a finite amount of space in which to represent them. Floating point representation is how we typically deal with approximating the real numbers in code. Floating point numbers can in principle have any level of precision, but the most common are: + +- `float`: 32 bits (4 bytes), +- `double`: 64 bits (8 bytes), +- `long double`: Usually 80 or 128 bits depending on processor, +- `half`: 16 bits, not part of the C/C++ standard but often used on hardware like GPUs. + +The data for floating point numbers are split into two parts, the mantissa and the exponent. It is comparable to scientific notation (when you write a number as $a \times 10^b$) except that it uses powers of 2 instead of 10. We can write a floating point number $n$ as + +$n = m \times 2^e$ + +where $m$ is the mantissa and $e$ is the exponent. The mantissa is usually represented such that there is a radix point after the first significant (non zero) digit. In other words, the binary `001011` would represent the mantissa `1.011` (again, in base 2). The exponent is a signed integer. + +The size of the datatype affects both the precision of the mantissa and the range of values for the exponent (and therefore how large and small the values represented can be). + +- `float` has around 7 significant figures +- `double` has around 16 significant figures + +Floating point computation **is not exact**. + +- Adding values of very different sizes leads to significant loss of precision since values must be converted to have the same exponent to be added together. This means the difference in scale is pushed into the mantissa, which then loses precision due to leading `0` digits on the smaller number. In some cases the smaller number may be so small that the closest representable number with that exponent is `0` and so the addition is lost completely. +- Subtracting values which are close in size leads to cancellation of many digits and a result with far fewer significant digits and therefore lower precision. +- Identities from real arithmetic do not necessarily hold, in particular **addition and multiplication are not associative**, so $(a + b) + c \neq a + (b + c)$ in floating point! +- Handling these difficulties in numerical methods is a major field in and of itself. Many numerical algorithms are specially crafted to correct rounding errors in floating point arithmetic. + +### Floating Point Precision + +Higher precision floating point numbers (like `double` as opposed to `float`) will give more accurate results when doing numerical work, but may also be slower to perform operations. Historically `double` operations have taken more time to compute than `float` operations, although this is no longer typically the case on modern CPUs. Nevertheless, if you are exploiting SIMD registers for maximal performance, fewer `double` values can fit in an available register and therefore fewer operations can be performed in a given amount of time. Some fast algorithms use single precision `float` or even half precision floating point numbers in areas where the results will not be significantly impacted by this. This is particularly common in areas like statistics and machine learning where the statistical variance is much larger than the precision of the floating point numbers. You must always bear in mind that the use of lower precision floating point types can lead to numerical instability from cancellation errors or division/multiplication by extreme values causing under or overflow. You should always have tests for robustness and precision to check that any compromises made to precision are acceptable. + +## Containers + +Containers are important to most programming languages; almost all programmers will need to work on things like lists of data, or vectors and matrices. Some commonly used containers, that we will use repeatedly throughout this course, are: + +- `std::array`: a type for storing exactly `N` elements of type `T`, with `N` known at compile time. +- `std::vector`: a type for storing arbitrary numbers of elements of type `T`. +- `std::map`: a type for storing key-value pairs where keys are unique and have type `A` and values have type `B`. + diff --git a/00introCS/dataRepresentation.md b/00introCS/dataRepresentation.md new file mode 100644 index 000000000..8ba284906 --- /dev/null +++ b/00introCS/dataRepresentation.md @@ -0,0 +1,99 @@ +--- +title: Data Representation +--- + +# Background: Basic Data Representation + +In order to write effective scientific programs, you will need to be able to translate real world and mathematical concepts into some kind of data representation. This is arguably the most fundamental process to understand in computing, since nothing can be worked with that cannot be represented. You may find that standard representations for many common objects (like vectors, complex numbers etc.) already exist, but it is absolutely vital that you understand how to construct these yourself. In this section, we will look at some examples of how to translate concepts into data representations in a language agnostic way. + +## What is a data representation? + +In order to represent something on a computer we need to have a properly formalised idea of exactly what information needs to be stored and how. Computers themselves have only sequences of zeros and ones to work with, and the computer itself doesn't know what it is you're trying to represent with it and can't make any decisions on its own about the correct thing to do with them. + +Later in this course we will look at the object-oriented approach of combining data and functionality together, but for now we will restrict ourselves to the more fundamental topic of data alone. + +## Defining structures + +Programming languages usually provide us with a number of basic types of data that we can utilise, including: + +- integers +- real numbers (approximated as "floating point") +- characters and strings +- arrays / lists + +Almost all languages, including C++ and Python, will give us ways of bundling data together into a larger structure that a collection of named _fields_ of various types. In C++ and Python these are called _classes_[^struct-class]. Deciding what fields are needed for a class is a a fundamental skill in translating your ideas into computable data. + +In C++, we must choose explicitly both the name and the _type_ of the data being held: is it an integer, or string, a floating point number? This is because C++ is _statically typed_ (variables must have a well known type that cannot change over the course of the program) whereas a language like Python is _dynamically typed_ (variables have the type of whatever data is assigned to them, and their type can change when their data is updated). + +[^struct-class]: Some of you may be familiar with _structs_ from C; the `struct` keyword from C is also valid in C++ and is also used to define a class. When declaring a data type with `struct` the members are public by default, whereas when using `class` the members are private by default; this is the only distinction made in C++ between these two keywords. + +## Example 1a: a circle + +Suppose our model requires us to represent a circle in a 2D space. This could be because we need to draw it for a graphical output, or we need to keep track of everything that happens within a given radius of some object, or perhaps we need to do collision detections for a physical simulation. What information do we need to store in order to represent a circle on a computer? + +In order to answer this question, we should start by thinking about the least information necessary to fully define a circle: it will need a position $(x,y)$ and a radius, $r$. Everything else about a circle can be derived from this information: the circumference, the area, whether something is inside or outside of the circle, and so on. In a purely mathematical sense, $x$, $y$, and $r$ are all _real numbers_; on a computer our best way to represent real numbers is _floating point_ numbers, i.e. `float` or `double`. A class to represent a circle could look like this in C++: + +```cpp +class Circle +{ + public: + double centre_x; + double centre_y; + double radius; +}; +``` + +or like this in Python: + +```python +class Circle: + centre_x: float + centre_y: float + radius: float +``` + +Note that in Python we have included the optional type annotations for clarity, and that `float` in Python has double precision and is therefore equivalent to `double` in C++. + +## Example 1b: adding colour to a circle + +Consider now the graphical application of our circle class: we don't just need to know where it is and how big it is any more, but also what colour to render it. A colour on the computer is usually represented as a triple $(r, g, b)$ of _red_, _green_, and _blue_ intensities from 0 to 255. We can write a simple class to represent a colour then in the following way: + +```cpp +class Colour +{ + public: + int r; + int g; + int b; +}; +``` + +If we've defined a `Colour` class, we can add it to our `Circle` class: + +```cpp +class Circle +{ + public: + double centre_x; + double centre_y; + double radius; + Colour colour; +}; +``` + +This kind of nested data structure is very helpful in building up complex structures, and more more readable than giving the `Circle` class its own separate red, green, and blue member variables. + +The Python equivalent would be: + +```python +class Colour: + r: int + g: int + b: int + +class Circle: + centre_x: float + centre_y: float + radius: float + colour: Colour +``` \ No newline at end of file diff --git a/00introCS/functionRepresentation.md b/00introCS/functionRepresentation.md new file mode 100644 index 000000000..c7bdc43c7 --- /dev/null +++ b/00introCS/functionRepresentation.md @@ -0,0 +1,175 @@ +--- +title: Function Representations +--- + +# Background: Overview of Function Representations + +A _function_ is one of the most basic components of any mathematical model, and so it is very important in scientific computing to be able to represent functions in appropriate ways. The simplest way of thinking of a function is to simply have it be a function in the programming language, like this C++ function: + +```cpp +double square(double x) +{ + return x*x; +} +``` + +or this Python function: + +```python +def square(x): + return x*x +``` + +But what if our function needs to be plotted? Or what if our function is not easily computable or expressed in our programming language? What if our function isn't an arithmetic function but a mapping between two finite sets of objects? Depending on the context, representing a function may require very different things from us, and so here we outline a variety of common function representations that you should be aware of and examples of their applications. + +## Computable Functions as C++ Functions / Callable Objects / Lambda Expressions + +If we have an algorithm to calculate outputs from inputs, this can be represented directly as a function in the programming language. Most modern programming languages, including both Python and C++, provide ways of passing functions around like data: in C++ we have _function pointers_, _lambda expressions_, and _callable objects_. The various ways of handling functions in C++ can all be wrapped in the standard library type `std::function`, which we'll cover in this course. + +As a concrete example, suppose we want to write a function called `filter` that takes a list of integers and an arbitrary function $f: \mathbb N \rightarrow \mathbb B$ and returns a new list of all the elements $x$ where $f(x) = \text{True}$. We need a way of modelling an arbitrary function $f$ from integers to bools, for example to find all the even elements of a list. In Python the implementation could look like this: + +```python +def filter(list_in, f): + return [x for x in list_in if f(x)] + +is_even = lambda x: (x%2) == 0 + +l1 = [1,2,3,4,5] +l2 = filter(l1, is_even) +``` + +In `filter`, `f` is an argument that is an arbitrary function which, applied to an element of the list, returns true or false. `is_even` is a _lambda expression_, which defines a function that checks if the input `x` is even. When we pass `is_even` into `filter` along with a list, we get the list of even elements back, but `filter` is very general because it will work for whatever function we decide to pass in takes integers and returns bools. + +The equivalent fragment in C++ would look like this: + +```cpp +#include +#include + +using std::vector; +using std::function; + +vector filter(const vector &list_in, function f) +{ + vector output; + for (auto x : list_in) + { + if (f(x)) + { + output.push_back(x); + } + } + return output; +} + +int main() +{ + auto is_even = [](int x){return x%2 == 0;}; + + vector l1{1, 2, 3, 4, 5}; + vector l2 = filter(l1, is_even); + + return 0; +} +``` + +Don't worry about the C++ syntax too much right now; the extra details will be explained as the course unfolds and is mostly there to support C++'s more rigorous programming style when it comes to types. It's more important to note that the structure is still the same: we have a lambda expression `is_even` which is a function that can be called that takes an integer and returns a Boolean; it is passed to a function `filter` that takes a list of elements and an arbitrary function `f` and applies `f` to each element to determine which will be added to the output list. + +## Sampled Functions as Lists/Vectors + +A sampled function means storing a series of values for a function for different inputs; it's a way of _discretising_ a function i.e. turning a continuous function (or signal) into a finite set of discrete points. You will likely be familiar with sampled functions from plotting graphs. Most plotting routines for a function $f(x)$ take a list of $(x, f(x))$ pairs (or a list $[x_0, ..., x_{n-1}]$ and a list $[f(x_0), ..., f(x_{n-1})]$). + +Discretising functions is an important tool in science and engineering, and has myriad applications: + +- capturing incoming signals from measurement equipment, +- representing numerical solutions to equations with no analytic form, +- pre-computing functions which are expensive to compute, +- calculating probability distributions by building up histograms of random events, +- and many more! + +As an example, suppose we monitor _power usage_ at regular intervals. Physically, the total energy used is related to power by + +$E = \int P \,dt$. + +We don't have an analytic form of the function $P(t)$, so we can't integrate it analytically. Instead, we have a series of values for the power at different times, and we can integrate it using a numerical method such as the trapezium rule. We can visualise this representation of the power function as the plot below: + +![image](./img/PowerPlot.png) + +To integrate the function, we need to access the elements of an array of power / time values. The python code to integrate an arbitrary function $f(x)$ like this would look something like this: + +```python +def trapezium_integrate(x, f): + n = len(x) + assert(len(f) == n) + tot = 0 + for i in range(0, n-1): + tot += (x[i+1] - x[i]) * (f[i+1] + f[i]) * 0.5 + return tot +``` + +and the equivalent C++ code like this: + +```cpp +double trapezium_integrate(std::vector x, std::vector f) +{ + if(x.size() != f.size()) + { + throw std::runtime_error("Number of sample points for x and f do not match."); + } + double tot = 0; + for(std::size_t i = 0; i < x.size(); i++) + { + tot += (x[i+1] - x[i]) * (f[i+1] + f[i]) * 0.5; + } + return tot; +} +``` + +### Special Case: Interpolated Functions + +Sometimes when we have a discretised function we need to have a strategy for approximating values between the sample points. As well as providing values for the function at arbitrary points, these can also be used to calculate useful approximations of integrals and gradients. + +Two of the most important interpolation schemes are: + +- Linear interpolation: each sampled point is joined to the next by a straight line and values are calculated using $f(x) \approx f(x_i) + (f(x_{i+1}) - f(x_i))\frac{x - x_i}{x_{i+1} - x_i}$ for $x_i \le x \le x_{i+1}$. +- Polynomial spline interpolation: segments of the function are approximated as polynomials (usually cubic) satisfying some constraints on the continuity of the function and its first derivative. + +## Discrete Functions as Associative Arrays (Maps/Dictionaries) + +Although most functions in physical sciences are continuous functions over some continuous space like the real numbers, in computing we will very often have to deal with _discrete functions_, i.e. functions whose domain is a countable set. Common domains might be the natural numbers (for example, an ID for an object), characters/strings, a set of labels, and so on. + +Discrete functions can be represented using an _associative array_; in C++ the standard library provides `std::map`, and you may have come across Python's `dictionary` type which does the same job. An associative array stores _key-value pairs_, allowing you to look up a value for a particular key. When used to represent a function, the _key_ is an element of the domain, and the _value_ is an element of the range. + +Suppose we have a labelled graph, with a set of vertices $V$ and set of labels $L$. We may want to construct a function $f: L \rightarrow \mathcal{P}(V)$ that returns all the vertices with a given label. If our labels are strings, we can represent this function as an associative array (i.e. map or dictionary) from strings to integers (the ID of the vertex). We can see this clearly in the example code below in Python: + +```python +def ConstructLabelSubsetFunction(graph): + lsf = {} + for i in range(graph.num_vertices): + s = graph.get_label(i) + if s in lsf.keys(): + lsf[s].append(i) + else: + lsf[s] = [i] + return lsf +``` + +and in C++: +```cpp +std::map> ConstructLabelSubsetFunction(LabelledGraph graph) +{ + std::map> lsf; + for(int i = 0; i < graph.num_vertices; i++) + { + std::string s = graph.get_label(i); + lsf[s].push_back(i); + } + return lsf; +} +``` + +Note that we have taken the implementation of the labelled graph data structure to be implicit; all we have required here is that we can get the number of vertices and the label of a given vertex. + +You may notice that this example does have an algorithmic implementation: instead of constructing a map we could instead iterate over all of our vertices every time we want to find the set of vertices with a given label. If we need to query this function frequently, this approach is likely to be much less efficient than constructing our map once and then finding the result for each label easily. + +Maps are also useful for constructing functions that have no clear algorithmic form, for example if we are constructing a mapping based on run time input or data files. \ No newline at end of file diff --git a/00introCS/img/PowerPlot.png b/00introCS/img/PowerPlot.png new file mode 100755 index 000000000..39105928f Binary files /dev/null and b/00introCS/img/PowerPlot.png differ diff --git a/00introCS/index.md b/00introCS/index.md new file mode 100644 index 000000000..541065742 --- /dev/null +++ b/00introCS/index.md @@ -0,0 +1,14 @@ +--- +title: "Preliminaries: Foundational Mathematics and Computer Science" +--- + +## Overview + +This course brings together students from different disciplines, with varying experience of programming and mathematics. It aims to help you make well-informed choices when developing research software. Some topics may therefore take a more computer science oriented approach than you have encountered before. + +The following pages are intended to lay out the expectations that we have for students on this course. These pages will not contain all of the basics of programming from first steps - we do expect you to come in with prior programming experience - but address some of the most common problems and clarifies the expectations for exams. In this section (and this section only) we will give all examples in both C++ and Python. (You will notice that the examples are usually almost identical except that C++ requires you to write your types more explicitly, and there is some extra punctuation!) + +- [Preliminary Mathematics](./preliminaryMathematics.md): this page will list the mathematics that we expect you to understand by the time you come to the exam. Note that most of these are focussed on understanding broad concepts and mathematical notations; there is not an expectation to be able to do complex mathematics beyond what is required for the analysis of algorithms section of the course. This mathematical background is vital for being able to understand problem descriptions. +- [Basic Types](./basicTypes.md): this page gives an overview of some of the most common data types used in programming, giving their C++ versions. You should be familiar with their equivalents already in at least one programming language. +- [Data Representation](./dataRepresentation.md): this page gives a preliminary overview of translating concepts into data that can be stored on a computer. This is generally assumed as a pre-requisite to the course as a foundation of programming technique, but some students may not have had to write their own data representations before so it is imperative that you understand how to do this. Writing data structures will be necessary for all forms of assessment. +- [Function Representation](./functionRepresentation.md): this is a special case of data representation where the object that we want to represent is some kind of function. There are a number of nuances based on the different kinds of functions we might talk about, and we expect some of the examples of function representation to be new to most students, so please make sure you are familiar with each of them. diff --git a/00introCS/preliminaryMathematics.md b/00introCS/preliminaryMathematics.md new file mode 100644 index 000000000..f22b8bf2e --- /dev/null +++ b/00introCS/preliminaryMathematics.md @@ -0,0 +1,104 @@ +--- +title: Preliminary Mathematics +--- + +# Background: Preliminary Mathematics + +A certain amount of mathematics is necessary to be able to write and reason about programs, or to describe computational problems precisely. As a scientific computing course, we will be looking at examples from a range of scientific disciplines which will use some amount of mathematics as a common language. So, while this course is intended for a wide audience and no problem will require specific domain knowledge from a particular science, we do require that you understand the meaning of some common mathematical notations and the basics of what they are describing. **This is especially important for the assessments as we will draw upon this language and notation when describing problems.** + +## Algebra and arithmetic + +We expect you to be able to understand equations in elementary algebra including polynomials, exponentials, and logarithms, and to solve straight-forward equations involving these terms. See e.g. the notes on complexity in week 7 for an idea of the kind of use of elementary algebra in the course. + +You should understand: + +- The meaning of _associative_ and _commutative_ operations. +- Sum notation $\sum_{i = 0}^{N} x_i = x_0 + x_1 + ... + x_N$. +- Product notation $\prod_{i=0}^{N} x_i = x_0 \times x_1 \times ... \times x_N$. + +## Sets and Functions + +You should understand: + +- What a set is, and notation for defining sets and set membership (e.g. $x \in A$). +- What a function is, and the function notation $f: A \rightarrow B$ (a function called $f$ that maps elements of set $A$ to elements of set $B$). +- What function composition is, and the following notation that if $f: A \rightarrow B$ and $g: B \rightarrow C$, then $g \circ f: A \rightarrow C$ is a function where $(g \circ f)(x) = g(f(x))$. +- What a one-to-one function (a.k.a a bijection) is, and the notation $f^{-1}$ for the inverse. +- Notation for numerical intervals: + - $[a, b]$ denotes the "closed interval", the range $a \le x \le b$ (includes the end points); + - $(a, b)$ denotes the "open interval", the range $a \lt x \lt b$ (excludes end points); + - $[a, b)$ denotes the "half-open interval", the range $a \le x \lt b$ (excludes one end point). +- Notation for, and meaning of, the following common sets: + - $\mathbb{N}$, the natural numbers; + - $\mathbb{Z}$, the integers; + - $\mathbb{R}$, the real numbers; + - $\mathbb{B}$, the booleans $\mathbb{B} = \{\text{True}, \text{False}\}$. +- Notation for the powerset of a set, $\mathcal{P}(A)$, the set of all subsets of the set $A$. This is useful for understanding functions that return collections of objects. For example, a function that takes an integer $n$ and returns all the elements of $A$ that are divisible by $n$ would be a function $f: \mathbb{N} \rightarrow \mathcal{P}(A)$ from the natural numbers to the powerset of $A$. + +You are not expected to have any further background in set theory or be able to write set theoretic proofs. + +## Logic + +You should understand: + +- Boolean (true/false) values and variables. +- Notation for propositional logic/boolean formulae ($A \land B$, $A \lor B$, $A \implies B$, $A \iff B$, $\lnot A$). +- Quantifiers ($\exists$ meaning "there exists", $\forall$ meaning "for all"). +- Notations for predicates $P(x)$. + +For example you should be able to understand $\forall x \in A . P(x) \implies Q(x)$ to mean "for any element $x$ in the set $A$, if $P(x)$ is true then $Q(x)$ is true", and $\exists y \in B . R(y)$ to mean "there exists an element $y$ in the set $B$ such that the formula $R(y)$ is true". + +## Calculus + +You should understand: + +- The basic idea of integration and its notation $\int_a^b f(x) dx$. + - You do not need to be able to _do_ integration yourself, but you should be familiar with what it means and be able to understand a simple numerical integration method like the [trapezium rule](https://en.wikipedia.org/wiki/Trapezoidal_rule). +- The basic idea of differentiation and its notation $\frac{df}{dx}$. + - You do not need to be able to do the differentiation itself but you should understand its meaning well enough to understand the basic numerical approximation $\frac{df}{dx} \approx \frac{f(x + \Delta x) - f(x - \Delta x)}{2 \Delta x}$. + +## Probability and Statistics + +You should understand: + +- The idea of a probability value, and that probability lies between $0$ and $1$ inclusive. +- The meaning of, and how to calculate, the following: + - mean, + - median, + - mode, + - variance, + - standard deviation. + +## Vectors and Matrices + +You should understand: + +- What a vector and a matrix are. +- Vector addition / subtraction. +- Vector dot product $v \cdot w$ and how to calculate it. +- Matrix addition / subtraction. +- Matrix-matrix product $M N$ and how to calculate it. +- Matrix-vector product $M v$ and how to calculate it. +- Compatibility of matrices/vectors for different operations based on their dimensions (e.g. vector dot product requires both vectors to be the same length). +- Vector notation $v_i$ or $v[i]$ for an element of a vector. +- Matrix notation $M_{ij}$ for an element of a matrix. + +## Graphs + +Graphs may be unfamiliar to those of you without a significant computing background, so we will give a brief explanation here as they are crucial in many computer science problems. + +A graph is a set of vertices, $V$, and a set of edges, $E$. Each element of the edge set is a pair of vertices $(v_i, v_j)$ denoting that $v_i$ is connected to $v_j$ by an edge. Graphs are represented visually in the following way: + + +There are a number of kinds of graphs that are important to know about. + +- Simple graphs: these are undirected graphs where there is at most one edge connecting any two vertices, and no edge connects a vertex to itself. +- Multigraphs: these are graphs where there may be more than one edge connecting two vertices. **In general the word "graph" will refer to this more general kind of graph.** +- Labeled graphs: these are graphs where vertices and edges may also have a label carrying additional information. For example in a _flow network_ edges are labelled by their capacity. +- Directed graphs: these are graphs where the edges are directed and so the ordering of the pair $(v_i, v_j)$ is important: $v_j$ can be reached from $v_i$ but not vice versa because the edge is directed from $v_i$ to $v_j$. +- Directed acyclic graphs: these are directed graphs where there is no path of non-zero length, following the directions of the edges, from a vertex back to itself. +- Trees: these are directed graphs where every vertex has at most one predecessor (vertex linked by an incoming edge), but may have multiple successors (vertices linked by an outgoing edges). In trees the predecessor is called a "parent" and the successors are called "children". A vertex with no children is sometimes called a "leaf", and a vertex with multiple children is sometimes called a "branch". Vertices are usually labelled with some kind of data. **These are arguably the most widely used graph structure in computing and are essential for many algorithms and data structures.** +- Binary trees: these are trees where each vertex has at most two children (usually called the left and right child). A common definition is that a vertex either has _no_ children (a leaf) or it has exactly two (a branch). Binary search trees are a very important example of binary trees that we will talk about on this course. +- Linked lists: these are directed graphs where vertices are labelled with data and each vertex has at most one successor (but may have multiple predecessors). These are a common data structure for many algorithms processing lists of data but are less commonly used in languages like C++ due to the performance benefits of random access arrays (see the data structures notes for more information). + +You will not need to know any graph algorithms or results of graph theory by heart, but you should understand what a graph is, what vertices and edges are, and what a path from one vertex to another is. You should also be able to recognise the kinds of graphs described above. You can find information about how we represent graphs in the notes on data structures. \ No newline at end of file diff --git a/01projects/UsingTheTerminal.md b/01/UsingTheTerminal.md similarity index 100% rename from 01projects/UsingTheTerminal.md rename to 01/UsingTheTerminal.md diff --git a/01projects/index.md b/01/index.md similarity index 68% rename from 01projects/index.md rename to 01/index.md index f3bf6dfdc..660f993c2 100644 --- a/01projects/index.md +++ b/01/index.md @@ -1,9 +1,22 @@ --- title: "Introduction to C++" -author: - - Krishnakumar Gopalakrishnan --- +## Course Outline + +This is a broad course that will touch on many different topics in order to give you a strong foundation from which to make informed choices in the context of software engineering. To do this, we will need to not only develop an understanding of the C++ programming language itself, but also begin to understand the architecture of our computer systems and the formal analysis of programming problems and their solutions. + +1. Introduction to C++ and data representation. +2. The standard library, classes, and pointers. +3. The cmake build system, compiler optimisation, and testing. +4. Polymorphism: inheritance and templates. +5. Code structure, design patterns, and error mechanisms. +6. Libraries and tools. +7. Parallel models: shared memory (openMP) and distributed memory (MPI) parallelism. +8. Performance programming, cache behaviour, and parallel scaling. +9. Parallel programming strategies and asynchronous MPI. +10. Algorithm analysis: serial complexity and PRAM work-depth. + ## Contents This week we will introduce you to the basics of writing, compiling, and running a program using C++. We will go through most things for this class with live demonstrations, but you may find it helpful to read over the notes ahead of class anyway, and you can refer back to them throughout the course. diff --git a/01projects/sec01IntroToCpp.md b/01/sec01IntroToCpp.md similarity index 87% rename from 01projects/sec01IntroToCpp.md rename to 01/sec01IntroToCpp.md index 9be90658b..66a621682 100644 --- a/01projects/sec01IntroToCpp.md +++ b/01/sec01IntroToCpp.md @@ -6,13 +6,13 @@ title: Introduction to C++ ## What is C++? -C++ is a relatively low-level*, relatively old, general-purpose programming language, commonly used for programs that require high performance. First released in 1985 by Bjarne Stroustrup, it was intended as a successor to C, primarily to add object-oriented features. Now, C++ is multi-paradigm, meaning that it includes features that enable object-oriented, functional, and procedural programming. It inherits many characteristics from C: it is compiled, has a static type system, and allows manual memory management. All these features ultimately enable C++ to perform extremely well; these same features can be tricky to use effectively and can be a source of bugs. It is the balance between programmer productivity and performance that allows C++ to be used to build large, complex, fast applications and libraries. +C++ is a relatively low-level[^low-level], relatively old, general-purpose programming language, commonly used for programs that require high performance. First released in 1985 by Bjarne Stroustrup, it was intended as a successor to C, primarily to add object-oriented features. Now, C++ is multi-paradigm, meaning that it includes features that enable object-oriented, functional, and procedural programming. It inherits many characteristics from C: it is compiled, has a static type system, and allows manual memory management. All these features ultimately enable C++ to perform extremely well; these same features can be tricky to use effectively and can be a source of bugs. It is the balance between programmer productivity and performance that allows C++ to be used to build large, complex, fast applications and libraries. Since 1998, C++'s development has been governed by an ISO working group that collates and develops new C++ features into a *standard*. Since 2011, a new standard has been released every 3 years, with compiler support for new features lagging behind by up to 3 years. This course will assume the use of the `C++17` standard, which is presently widely supported by compilers. (Compiler support tends to lag behind the release of the C++ standard, since the compiler developers need time to implement it and check that the new compilers work properly!) -> \* The exact meaning of "low level" depends on whom you ask, but in general _lower level_ languages are languages that more directly represent the way that the machine works, or give you control over more aspects of the machine. By contrast, _higher level_ languages abstract more of that away and focus on defining the outcome that you want. Originally, a "low level" language refers to _assembly code_, which is where the instructions that you write are (a human readable version of) the instruction set of the machine itself! This code is by its nature specific to a given kind of machine architecture and therefore not portable between systems, and doesn't express things in a way that is intuitive to most people. High level languages were introduced to make code easier to understand and more independent of the hardware; the highest level languages, like Haskell, are highly mathematical in their structure and give hardly any indication of how the computer works at all! C++ falls somewhere in the middle, with plenty of high level abstractions and portability, but it still gives us some features associated with low level programming like direct addressing of memory. This extra degree of control is very valuable when you need to get the best out of systems that require high performance or have limited resources. +[^low-level]: The exact meaning of "low level" depends on whom you ask, but in general _lower level_ languages are languages that more directly represent the way that the machine works, or give you control over more aspects of the machine. By contrast, _higher level_ languages abstract more of that away and focus on defining the outcome that you want. Originally, a "low level" language refers to _assembly code_, which is where the instructions that you write are (a human readable version of) the instruction set of the machine itself! This code is by its nature specific to a given kind of machine architecture and therefore not portable between systems, and doesn't express things in a way that is intuitive to most people. High level languages were introduced to make code easier to understand and more independent of the hardware; the highest level languages, like Haskell, are highly mathematical in their structure and give hardly any indication of how the computer works at all! C++ falls somewhere in the middle, with plenty of high level abstractions and portability, but it still gives us some features associated with low level programming like direct addressing of memory. This extra degree of control is very valuable when you need to get the best out of systems that require high performance or have limited resources. ## Why are we using C++? diff --git a/01projects/sec02CppSyntax.md b/01/sec02CppSyntax.md similarity index 100% rename from 01projects/sec02CppSyntax.md rename to 01/sec02CppSyntax.md diff --git a/01projects/sec03MultipleFiles.md b/01/sec03MultipleFiles.md similarity index 100% rename from 01projects/sec03MultipleFiles.md rename to 01/sec03MultipleFiles.md diff --git a/01projects/sec05Git.md b/01/sec05Git.md similarity index 100% rename from 01projects/sec05Git.md rename to 01/sec05Git.md diff --git a/03cpp2/index.md b/03cpp2/index.md index 4cac1cb60..52535d465 100644 --- a/03cpp2/index.md +++ b/03cpp2/index.md @@ -8,28 +8,30 @@ title: "Week 3: Error Handling and C++ Projects" This week we'll be looking at how to work with larger and more complex C++ projects. We'll cover approaches to error handling in C++, including _exceptions_, how to manage building significant projects using C++ and work with an external library, and how to test our code rigorously. -* [Exceptions](./sec01Exceptions.html) +_* [Exceptions](./sec01Exceptions.html) - Error handling basics - Exception types - Try and Catch statements - - Defining our own Exception types -* [Other Error Handling Mechanisms](./sec02ErrorHandling.html) + - Defining our own Exception types_ + + +_* [Other Error Handling Mechanisms](./sec02ErrorHandling.html) - Error return codes - - Optional types + - Optional types_ + + * [CMake Basics](./sec03CMakeBasics.html) - Basics of CMake files - Project directory structure - Setting C++ standards +* [Code Optimisation](./sec03Optimisation.md) + - Automated optimisation and compiler flags + - SIMD vectorisation + - Constant expressions and compile time evaluation * [Unit Testing](./sec04UnitTesting.html) - Installing the Catch2 testing library - Adding Catch2 to your CMake files - Testing Strategy - Writing tests -If you would like more detail on CMake and build systems, you can read the following optional notes. These are not necessary to do this week's exercises, but may be useful during the course and assignments. - -* [Build Systems](./sec05SoftwareBuilds.html) -* [CMake Background](./sec06CMakeBackground.html) -* [CMake "Hello World"](./sec07CMakeHelloWorld.html) -* [Building with CMake](./sec08BuildHelloWorld.html) - +If you want to find out about more features of cmake and how to use it for even more complex projects, I highly recommend the [cmake tutorial](https://cmake.org/cmake/help/latest/guide/tutorial/index.html). (We will cover how to add libraries later in the course, but otherwise will not be interested in advanced features of cmake.) diff --git a/03cpp2/sec02ErrorHandling.md b/03cpp2/sec02ErrorHandling.md deleted file mode 100644 index d24e25cb4..000000000 --- a/03cpp2/sec02ErrorHandling.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -title: Other Error Mechanisms ---- - -# Other Error Mechanisms - -There are some other ways of handling errors that are worth being aware of outside of using exceptions. In this section we discuss _return codes_, which are particularly common in C-based external libraries and legacy code from earlier versions of C++, and `std::optional`, a special type which can represent the absence of a value. - -## Return Codes - -Return codes are common in programming languages like C which do not have exceptions, and so will be frequently encountered by C++ programmers when using libraries built in C. (C is a very common languages library because C has become the lingua franca of programming languages, with most popular languages having a way to interface with C code.) - -The return codes approach can also be useful in its own right in C++, when we don't want to interrupt the program flow in the same way, or when errors are common and the exception overhead starts to become high. High frequencies of exceptions can cause particular problems for multi-threaded programming (you can read about [an example here](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2544r0.html) if you are interested). - -In the return code approach, you define a function as returning an `int`, and the convention is to return `0` for a successful execution. Any other number encodes an error of some kind, and you can thus assign different meanings to different integers. Because we've used the return value to report success/failure, the meaningful output of the function is placed into a mutable argument (reference or pointer). This is one of the disadvantages of this approach, as it obsfuscates the logical structure of the function. - -Consider this example for getting the first element of a vector (which may be empty): -```cpp -#include -#include - -using std::vector; - -int head(vector v, int &x) -{ - if(v.empty()) - { - return 1; // error code - } - else - { - x = v[0]; - return 0; // success code - } -} - -int main() -{ - vector v{5, 9, 4, 3}; - int x; - if(!head(v, x)) - { - std::cout << "It was empty." << std::endl; - } - else - { - std::cout << x << std::endl; - } - - return 0; -} -``` -- In this approach we have to declare our variable to hold the value first, and then pass it into the function. -- We need to check the output of the function to see if it is successful. Unlike an exception, if we need to handle it further up the call stack then we have to check it separately **at every level until we reach the calling scope where we can handle it**. If functions are nested, then every intermediate function between where the error occurs and where it is handled needs to check for an error, and then return early with its own error code to be checked by the function that called it. This means that we often end up with a lot more error checking code using this approach. -- Assigning appropriate error codes can be tricky, and you need to remember what they all mean. Using an accessible namespace to declare some `const` variables to give meaningful names to different error codes can be useful here, but beware that library code that you interface with will use their own conventions! - - -## `std::optional` - -Some functions might make most sense to conceptualise as returning either a _value_ or _nothing_. Examples might be getting the first element of a list (either the first element of a list or nothing if the list is empty), or looking up a value in a key-value map (return the value if the key is in the map or nothing if it isn't). These can be handled using exceptions or error codes of course, but a special _nothing_ value can be useful sometimes to explicitly represent this value. -- Having a special _nothing_ value can help to avoid unitialised variables. This can happen for example when we want to assign a new variable from some function which may fail. -- A _nothing_ value can also be very useful for class design. For example, if you have a class representing some student data, we could set a grade for a course to be either a _value_ (the mark awarded) or _nothing_ (no mark is given yet). This avoids potentially misleading data using default values. - - It can also be very useful for avoiding null pointers when objects need to point to other objects, but we'll discuss pointers next week! -- A _nothing_ value can be propagated through the rest of your calculations like any other value. If your code properly handles the _nothing_ values, this can sometimes simplify the control flow of your program by allowing all your data to go through the same pipeline. - -In C++ this approach can be handled using `std::optional` (in C++17 onwards); it is similar to the concept of the _Maybe monad_ in Haskell and a number of other similar structures in various languages. - -Like `vector`, `std::optional` uses angle brackets to declare the type of value that it can hold. `std::optional` can either hold a value, or the special value `std::nullopt`. - -The following code uses `std::optional` to get the first element (or "head") of a `vector`. -```cpp -#include -#include -#include - -using std::vector; -using std::optional; -using std::nullopt; - -optional head(const vector &v) -{ - return v.size() > 0 ? optional(v[0]) : nullopt; -} - - -// This function defines the << operator for streaming an std::option -// to an output stream such as std::cout. -std::ostream& operator<<(std::ostream &os, optional x) -{ - if(x) // this is defined as: "if x has a value" - { - os << x.value(); - } - else // otherwise it must be nullopt - { - os << "nothing"; - } - return os; -} - -int main() -{ - vector v1{5,9,4,3}; - optional x = head(v); - - vector v2; - optional y = head(v2); - - std::cout << x << ", " << y << std::endl; - - return 0; -} -``` -The output for this program will be -``` -5, nothing -``` - -## Using types to forbid errors - -It is sometimes possible to exclude the possibility of certain kinds of errors by making use of the type system. - -Take for example the factorial function, which is only defined for $n >= 0$. One approach is to accept general integers and check for errors: -```cpp -int factorial(int n) -{ - if(n < 0) - { - throw std::logic_error("Factorial is undefined for n < 0"); - } - ... -} -``` -but we can eliminate this possibility entirely by using an unsigned integer type such as `uint` which is always greater than 0: -```cpp -uint factorial(uint n) -{ - ... -} -``` -Sometimes it is necessary (or, at least, more convenient) for us to have a function like `factorial` which takes an `int` rather than `uint` and checks for errors. We may need to pass in a variable that we can't guarantee is non-negative or the variable needs to hold different values at different times including negative values. However, where possible and reasonable, it's a good idea to use restrictive types to enforce properties that you want. - -- `uint` and `size_t` are unsigned types that can't be less than 0, so can be useful for mathematical functions which take non-negative integers. -- `std::array` variables have their size determined at compile time, so there is no need to check their length as you do with `std::vector`. - -Defining our own classes can also help us to avoid errors. If you write your class' internal logic so that certain properties are always true, then you don't have to check for them in the functions that you pass those objects to. Remember to test those classes thoroughly though! \ No newline at end of file diff --git a/07performance/sec03Optimisation.md b/03cpp2/sec03Optimisation.md similarity index 79% rename from 07performance/sec03Optimisation.md rename to 03cpp2/sec03Optimisation.md index d93c1b72c..eda01b205 100644 --- a/07performance/sec03Optimisation.md +++ b/03cpp2/sec03Optimisation.md @@ -6,13 +6,13 @@ Estimated Reading Time: 45 minutes # Compiler Optimisation and Compile-Time Evaluation -Compilation is the translation of our high level code (in this case C++) into machine code that reflects the instruction set of the specific hardware for which it is compiled. This machine code can closely reflect the C++ code, implementing everything explicitly the way that it's written, or it can be quite different from the structure and form of the C++ code **as long as it produces an equivalent program***. The purpose of this restructuring is to provide optimisations, usually for speed. Modern compilers have a vast array of optimisations which can be applied to code as it is compiled to the extent that few people could write better optimised assembly code manually, a task that rapidly becomes infeasible and forbiddingly time consuming as projects become larger and more complex. +Compilation is the translation of our high level code (in this case C++) into machine code that reflects the instruction set of the specific hardware for which it is compiled. This machine code can closely reflect the C++ code, implementing everything explicitly the way that it's written, or it can be quite different from the structure and form of the C++ code **as long as it produces an equivalent program**[^semantic-equivalence]. The purpose of this restructuring is to provide optimisations, usually for speed. Modern compilers have a vast array of optimisations which can be applied to code as it is compiled to the extent that few people could write better optimised assembly code manually, a task that rapidly becomes infeasible and forbiddingly time consuming as projects become larger and more complex. -There is another benefit to automated compiler optimisation. Compilers, by necessity, do produce hardware specific output, as they must translate programs into the instruction set of a given processor. This means that even if we have written highly portable code which makes no hardware specific optimisations, we can still benefit from these optimisations if they can be done by the compiler when compiling our code for different targets! As we shall see below, some processors may have different features such as machine level instructions for vectorised arithmetic which can be implemented by the compiler without changing the C++ code, producing different optimised programs for different hardware from a single, generic C++ code. +There is another benefit to automated compiler optimisation. Compilers, by necessity, do produce hardware specific output, as they must translate programs into the instruction set for some given architecture. This means that even if we have written highly portable code which makes no hardware specific optimisations, we can still benefit from these optimisations if they can be done by the compiler when compiling our code for different targets! As we shall see below, some processors may have different features such as machine level instructions for vectorised arithmetic which can be implemented by the compiler without changing the C++ code, producing different optimised programs for different hardware from a single, generic C++ code. As such, to get the best out of our C++ code we need to rely to some extent on automated optimisation by the compiler. This does not mean that we should not choose effective algorithms -- the compiler will not simply replace a slow sorting algorithm with a better one! -- but rather compiler optimisation should be used in conjunction with our own best practices for writing efficient software. -> *_What is considered an "equivalent program" is beyond the scope of this course, and falls under the field of programming language semantics (the study of the meaning of programs). For now you can take equivalence to mean that the results of two computations are the same whenever they provided with the same external inputs. Things like the CPU clock, and therefore timing information, would qualify as external inputs, and so changing the timing results of a computation doesn't change the meaning of the program._ +[^semantic-equivalence]: What is considered an "equivalent program" is beyond the scope of this course, and falls under the field of programming language semantics (the study of the meaning of programs). For now you can take equivalence to mean that the results of two computations are the same whenever they provided with the same external inputs. Things like the CPU clock, and therefore timing information, would qualify as external inputs, and so changing the timing results of a computation doesn't change the meaning of the program. ## Optimisation Trade Offs @@ -27,113 +27,6 @@ Code with optimisations applied will generally run faster, but there are a numbe 4. Standards Compliance. - Some optimisations are not compliant with floating point standards; in particular they may affect floating point computations by rearranging numerical expressions ("free re-associations"). Using these can jeopardise the accuracy of your programs. -## Constant Expressions and Compile-Time Evaluation - -We can tell the compiler to do some computations at compile-time instead of during run-time. Consider some simple code like this: - -```cpp -int x = 5; -int y = x*x + 12; -int z = factorial(5); -``` - -The variables $y$ and $z$ are the result of simple, deterministic expressions that depend only on information that we have at compile time. An equivalent program could look like this: - -```cpp -int x = 5; -int y = 37; -int z = 120; -``` - -This program clearly doesn't need to do any work at run-time in order to assign values to `y` and `z`, but we have lost the expressiveness of our original version (which makes the relationships between `x`, `y`, and `z` clear), and we would have to update `y` and `z` manually if we changed the initialisation of `x`. - -**Constant expressions** provide us with a way of ensuring that we can write expressive code like the first example _and_ forcing the compiler to evaluate the expressions and replace them with their results at compile time. This is in particular useful for more complex functions that could be time-consuming and runtime and are less likely to be automatically optimised by the compiler. - -### Constant Expression Syntax - -A variable or a function can be declared as a constant expression using `constexpr`. For example: - -```cpp -constexpr int add(int a, int b) -{ - int c = a + b; - return c; -} - -int main() -{ - constexpr int x = 5; - constexpr int y = add(x, 18); - - return 0; -} -``` - -The initialization of `x` is essentially equivalent to `const x = 5` in this case. There is an additional restriction on `constexpr` compared to `const` however, which is that the initialisation of a `const` variable can happen at runtime, and depend on the run-time state, whereas the initialisation of a `constexpr` variable must be able to be performed at compile time. - -The use of `constexpr` for the function `add` enforces that this function can be evaluated on compile time and will not rely on any runtime information. This means that the variable assignment for `y` does not require runtime calculation, but the compiler will simplify `add(x, 18)` to `23` and simply assign the value to `y` without ever calling the function when the program is run. - -### Limitations of Constant Expressions - -Not all computations can be done at compile-time, and therefore there are a number of conditions that constant expressions must fulfil. The [complete list of conditions](https://en.cppreference.com/w/cpp/language/constexpr.html) is quite involved, but here are some key points to remember: - -- A `constexpr` function cannot contain `try` statements, since handling exceptions would require run-time state. - - You can _write_ a `throw` statement in a `constexpr` function but if the `throw` statement is reached during the compile time evaluation you will get a compiler error. This is useful because it will warn you if there is an error case in a compile-time evaluation, and because you can also call `constexpr` functions at run-time as well which can then handle the exception. -- A `constexpr` function cannot include uninitialised variables (e.g. `int z;`). -- A `constexpr` function cannot declare `static` variables, since they require maintaining a state between function calls. -- A `constexpr` function cannot call a non-`constexpr` function. This includes things like dynamic memory allocation with `new`. However since a `constexpr` function can call other `constexpr` functions, you can also write a _recursive_ `constexpr` function! -- A `constexpr` function cannot declare a _non-literal_ type variable. [Literal types](https://en.cppreference.com/w/cpp/language/constant_expression.html#Literal_type) need to fulfil a variety of conditions, in essence they are simple types that can be worked with at compile time, so they must be able to be constructed using a `constexpr`, have trivial destructors (no custom destructor logic needs to be called), and not contain member variables of non-literal types. An example of a non-literal type is `std::vector` since it has non-trivial destruction logic (heap memory deallocation), but `std::array` _is_ a literal type since its size is known at compile time and so it can be stack-allocated and doesn't require any specialised destruction logic. -- A `constexpr` variable must be a literal type, and must be initialised by a constant expression e.g. a call to a `constexpr` function with argument _known at compile time_ (e.g. an explicit number like `2.4` or a `const`/`constexpr` variable known at compile-time). - -If your function is a _pure function_ that doesn't involve exception handling, dynamic memory allocation/deallocation, or calls to non-`constexpr` functions, then you can probably turn it into a `constexpr` function. - -**N.B. We are using C++17 on this course. The conditions for acceptable constant expressions vary across different C++ standards, so if you use a newer or older standard you may find some differences in what you are able to write and compile.** - -### Revisiting the Factorial Example - -Consider the following `constexpr` definition of `factorial`, where we have omitted `#include` statements and `std::` namespacing for brevity. - -```cpp -constexpr int factorial(int x) -{ - if(x < 0) - { - throw domain_error("Value " + to_string(x) + " is not within the domain of factorial (x >= 0)."); - } - else if(x == 0) - { - return 1; - } - else - { - return x*factorial(x-1); - } -} -``` -Notice that we are able to make use of branching logic with separate `return` statements and recursion in this definition. - -Now consider the following statements that could appear in main, and whether or not they will compile: - -```cpp -int main() -{ - const int x = 5; - int y = 3; - - constexpr int z = factorial(x); // Compiles okay: x is const - constexpr int r = factorial(y); // Doesn't compile: y is not const - constexpr int p = factorial(-3); // Doesn't compile: throw is reached - int q = factorial(-3); // Compiles okay: throws an exception at runtime - - return 0; -} -``` -Notice that we can only initialise a `constexpr int` variable with a call to `factorial` if the **argument is `const` and the function evaluation does not `throw`**. If we initialise a non-`const` variable using `factorial` then it will be treated as a runtime expression and so these conditions don't apply. - -### Other Uses of Constant Expressions - -Constant expressions are not just useful for run-time optimisation; they also allow use to write more expressive code anywhere where we need to know information at compile time, such as constant template arguments (e.g. the length of an `std::array`) and static memory allocation (e.g. `int x[...];`). - ## Compiler Optimisation Flags The GNU compiler (gcc) has a [large number of optimisation options](https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html), but for the most part one uses a smaller set of flags which enable batches of these options. These batches are selected to give you control over some of the downsides of optimisation procedures discussed above. @@ -147,6 +40,15 @@ The GNU compiler (gcc) has a [large number of optimisation options](https://gcc. There are others in the documentation linked above which I would recommend that you read. It also describes in detail the optimisations that are turned on at each level. +### Debugging and Testing + +As we have mentioned already, debugging optimised code can be substantially more difficult than debugging unoptimised code. Generally, optimised code should work in the same way as unoptimised code, and therefore: + +- Compile code without optimisations (or with `-Og`) for debugging and most development purposes other than profiling / benchmarking. +- Turn on appropriate optimisations when compiling for actual deployment. +- Run your unit tests on both unoptimised and optimised code. + - If you have tests which pass when unoptimised but fail when optimised, then the chances are you have a bug in your code causing [**undefined behaviour**](https://en.cppreference.com/w/cpp/language/ub). + ## Some Optimisation Examples In the following sections we will look at a few common compiler optimisations and their implications. It's important to bear in mind however that these optimisations are not _guaranteed_ to happen, and optimisation is usually a heuristic process. Analysing programs is complex, and you are certainly not guaranteed to get the optimal version of your compiled code out of the compiler, and the compiler will make decisions about what optimisations to apply where based on various rules of thumb that are different for each compiler (and target architecture). If you _need_ an optimisation to be applied that isn't guaranteed by the C++ standard then you should consider implementing it yourself directly in your source code. @@ -335,59 +237,134 @@ In general, you shouldn't worry too much about how optimised objects like these ## Floating Point Arithmetic -Floating point arithmetic is how we typically deal with approximating the real numbers in code. Floating point numbers can in principle have any level of precision, but the most common are: +As discussed in the notes on basic types, floating point arithmetic is imprecise in some important ways: -- `float`: 32 bits (4 bytes) -- `double`: 64 bits (8 bytes) -- `long double`: Usually 80 or 128 bits depending on processor -- `half`: 16 bits, not part of the C/C++ standard but often used on hardware like GPUs +- it is not associative, so `a + (b + c)` $\neq$ `(a + b) + c`, +- multiplication is not distributive over addition, so `a*(b + c)` $\neq$ `a*b + a*c`, +- addition and subtraction operations can introduce substantial rounding errors under certain conditions, for example adding a very small number to a very large number. -The data for floating point numbers are split into two parts, the mantissa and the exponent. It is comparable to scientific notation except that it usually uses powers of 2 instead of 10. +This has significant consequences for the optimisation of floating point arithmetic. -$n = m \times 2^e$ +### Optimisation of Floating Point Arithmetic -where $m$ is the mantissa and $e$ is the exponent. The mantissa is usually represented so there is a radix point after the first significant (non zero) digit. In other words, the binary `001011` would represent the mantissa `1.011` (again, in base 2). The exponent is a signed integer. +Since floating point computation is not exact, many statements which are mathematically equivalent using real numbers are not equivalent in floating point. Remember that the core principle of automated optimisation is that it should not change the meaning of the code - the outcome of a calculation. Floating point operations therefore place limitations on the kinds of optimisations that can be applied, and if performance is a major consideration you should try to write out your floating point operations in the most efficient way possible to begin with. (Integer arithmetic can be rearranged by the compiler because it is exact.) -The size of the data affects both the precision of the mantissa and the range of values for the exponent (and therefore how large and small the values represented can be). +Nevertheless, compilers _can_ optimise numerical code for speed by rearranging arithmetic operations, even floating point operations. While C++ optimisations are generally designed not to change the results of the calculations as written there are some, such as those enabled by `-ffast-math`, that allow for rearrangement of arithmetic according to the rules of _real numbers_. This allows for example the rearranging associations. This can be a powerful tool in some cases, not only allowing your numerical code to be rearranged into a more efficient format but also permitting the compiler to make the necessary reorderings of operations for vectorising floating point algorithms. There are however, significant drawbacks to using `fast-math` optimisations due to the way that it can change the meaning of your program. -- `float` has around 7 significant figures -- `double` has around 16 significant figures +Suppose we have a large number $N$ and two much smaller numbers $a$ and $b$ and we want to calculate $N + a + b$. We know from the way that floating point numbers work that adding small and large numbers leads to rounding errors, so the best order to add these number is $(a+b) + N$, to keep the precision of $(a+b)$ and maximise the size of the smaller number that we add to $N$. This is why re-associations in optimisation can cause a problem. Many precise numerical algorithms have been designed to minimise or compensate for rounding errors, and these can have their error corrections optimised away by fast-math because for _real_ numbers the error corrections would all be zero! + +**Do not use fast math optimisations for code containing precise numerical methods unless you have very strictly tested it for sufficient accuracy. The standard levels O1, O2, and O3 will not enable `fast-math`.** + +## Constant Expressions and Compile-Time Evaluation + +We can also tell the compiler to do some computations at compile-time instead of during run-time, saving us time when we run our code (possibly many times!) later. Consider some simple code like this: + +```cpp +int x = 5; +int y = x*x + 12; +int z = factorial(5); +``` -Floating point computation **is not exact**. +The variables $y$ and $z$ are the result of simple, deterministic expressions that depend only on information that we have at compile time. An equivalent program could look like this: -- Adding values of very different sizes leads to significant loss of precision since values must be converted to have the same exponent to be added together. This means the difference in scale is pushed into the mantissa, which then loses precision due to leading `0` digits on the smaller number. In some cases the smaller number may be so small that the closest representable number with that exponent is `0` and so the addition is lost completely. -- Subtracting values which are close in size leads to cancellation of many digits and a result with far fewer significant digits and therefore lower precision. -- Identities from real arithmetic do not necessarily hold, in particular **addition and multiplication are not associative**, so $(a + b) + c \neq a + (b + c)$ in floating point! -- Handling these difficulties in numerical methods is a major field in and of itself. Many numerical algorithms are specially crafted to correct rounding errors in floating point arithmetic. +```cpp +int x = 5; +int y = 37; +int z = 120; +``` -### Floating Point Precision +This program clearly doesn't need to do any work at run-time in order to assign values to `y` and `z`, but we have lost the expressiveness of our original version (which makes the relationships between `x`, `y`, and `z` clear), and we would have to update `y` and `z` manually if we changed the initialisation of `x`. -Higher precision floating point numbers (like `double` as opposed to `float`) will give more accurate results when doing numerical work, but may also be slower to perform operations. Historically `double` operations have taken more time to compute than `float` operations, although this is no longer typically the case on modern CPUs. Nevertheless, if you are exploiting SIMD registers for maximal performance, fewer `double` values can fit in an available register and therefore fewer operations can be performed in a given amount of time. Some fast algorithms use single precision `float` or even half precision floating point numbers in areas where the results will not be significantly impacted by this. This is particularly common in areas like statistics and machine learning where the statistical variance is much larger than the precision of the floating point numbers. You must always bear in mind that the use of lower precision floating point types can lead to numerical instability from cancellation errors or division/multiplication by extreme values causing under or overflow. You should always have tests for robustness and precision to check that any compromises made to precision are acceptable. +**Constant expressions** provide us with a way of ensuring that we can write expressive code like the first example _and_ forcing the compiler to evaluate the expressions and replace them with their results at compile time. This is in particular useful for more complex functions that could be time-consuming and runtime and are less likely to be automatically optimised by the compiler. -### Optimisation of Floating Point Arithmetic +### Constant Expression Syntax -Since floating point computation is not exact, many statements which are mathematically equivalent using real numbers are not equivalent in floating point. As mentioned above, compiler optimisations should not change the meaning of the code - the outcome of a calculation. Floating point operations therefore place limitations on the kinds of optimisations that can be applied, and if performance is a major consideration you should try to write out your floating point operations in the most efficient way possible to begin with. +A variable or a function can be declared as a constant expression using `constexpr`. For example: -Nevertheless, compilers can optimise numerical code for speed by rearranging arithmetic operations, even floating point operations. (Integer arithmetic can be rearranged by the compiler because it is exact.) While C++ optimisations are generally designed not to change the results of the calculations as written there are some, such as those enabled by `-ffast-math`, that allow for rearrangement of arithmetic according to the rules of _real numbers_. This allows for example the rearranging associations. This can be a powerful tool in some cases, not only allowing your numerical code to be rearranged into a more efficient format but also permitting the compiler to make the necessary reorderings of operations for vectorising floating point algorithms. There are however, significant drawbacks to using fast-math optimisations due to the way that it can change the meaning of your program. +```cpp +constexpr int add(int a, int b) +{ + int c = a + b; + return c; +} -Suppose we have a large number $N$ and two much smaller numbers $a$ and $b$ and we want to calculate $N + a + b$. We know from the way that floating point numbers work that adding small and large numbers leads to rounding errors, so the best order to add these number is $(a+b) + N$, to keep the precision of $(a+b)$ and maximise the size of the smaller number that we add to $N$. This is why re-associations in optimisation can cause a problem. Numerical algorithms with factors which compensate for rounding errors can have their error corrections optimised away by fast-math, because for _real_ numbers the error corrections would be zero and the compiler can identify them as redundant! +int main() +{ + constexpr int x = 5; + constexpr int y = add(x, 18); -**Do not use fast math optimisations for code containing precise numerical methods unless you have very strictly tested it for sufficient accuracy.** + return 0; +} +``` -## Inspecting Optimised Code +The initialization of `x` is essentially equivalent to `const x = 5` in this case. There is an additional restriction on `constexpr` compared to `const` however, which is that the initialisation of a `const` variable can happen at runtime, and depend on the run-time state, since a `const` variable just means that its value cannot change after it has been declared. One the other hand, the initialisation of a `constexpr` variable must be able to be performed at compile time. -The process of optimisation, and the optimised executable at the end of it, can feel rather confusing and obscure given that we are not directly in control of it. Nevertheless, we still need to make sure that our debugging and testing of our programs is thorough, and there are ways to get a better understanding of what the compiler has done to our code if we're willing to look at a lower level! +The use of `constexpr` for the function `add` enforces that this function can be evaluated on compile time and will not rely on any runtime information as long as the arguments are known at compile time. This means that the variable assignment for `y` can be evaluated at compile time; the compiler will simplify `add(x, 18)` to `23` and simply assign the value to `y` without ever calling the function when the program is run. -### Debugging and Testing +### Limitations of Constant Expressions -As we have mentioned already, debugging optimised code can be substantially more difficult than debugging unoptimised code. Generally, optimised code should work in the same way as unoptimised code, and therefore: +Not all computations can be done at compile-time, and therefore there are a number of conditions that constant expressions must fulfil. The [complete list of conditions](https://en.cppreference.com/w/cpp/language/constexpr.html) is quite involved, but here are some key points to remember: -- Compile code without optimisations (or with `-Og`) for debugging and most development purposes other than profiling / benchmarking. -- Turn on appropriate optimisations when compiling for actual deployment. -- Run your unit tests on both unoptimised and optimised code. - - If you have tests which pass when unoptimised but fail when optimised, then the chances are you have a bug in your code causing [**undefined behaviour**](https://en.cppreference.com/w/cpp/language/ub). +- A `constexpr` function cannot contain `try` statements, since handling exceptions would require run-time state. + - You can _write_ a `throw` statement in a `constexpr` function but if the `throw` statement is reached during the compile time evaluation you will get a compiler error. This is useful because it will warn you if there is an error case in a compile-time evaluation, and because you can also call `constexpr` functions at run-time as well which can then handle the exception. +- A `constexpr` function cannot include uninitialised variables (e.g. `int z;`). +- A `constexpr` function cannot declare `static` variables, since they require maintaining a state between function calls. +- A `constexpr` function cannot call a non-`constexpr` function. This includes things like dynamic memory allocation with `new`. However since a `constexpr` function can call other `constexpr` functions, you can also write a _recursive_ `constexpr` function! +- A `constexpr` function cannot declare a _non-literal_ type variable. [Literal types](https://en.cppreference.com/w/cpp/language/constant_expression.html#Literal_type) need to fulfil a variety of conditions, in essence they are simple types that can be worked with at compile time, so they must be able to be constructed using a `constexpr`, have trivial destructors (no custom destructor logic needs to be called), and not contain member variables of non-literal types. An example of a non-literal type is `std::vector` since it has non-trivial destruction logic (heap memory deallocation), but `std::array` _is_ a literal type since its size is known at compile time and so it can be stack-allocated and doesn't require any specialised destruction logic. +- A `constexpr` variable must be a literal type, and must be initialised by a constant expression e.g. a call to a `constexpr` function with argument _known at compile time_ (e.g. an explicit number like `2.4` or a `const`/`constexpr` variable known at compile-time). + +If your function is a _pure function_ that doesn't involve exception handling, dynamic memory allocation/deallocation, or calls to non-`constexpr` functions, then you can probably turn it into a `constexpr` function. + +**N.B. We are using C++17 on this course in all examples, assignments, and exams. The conditions for acceptable constant expressions vary across different C++ standards, so if you use a newer or older standard you may find some differences in what you are able to write and compile.** + +### Revisiting the Factorial Example + +Consider the following `constexpr` definition of `factorial`, where we have omitted `#include` statements and `std::` namespacing for brevity. + +```cpp +constexpr int factorial(int x) +{ + if(x < 0) + { + throw domain_error("Value " + to_string(x) + " is not within the domain of factorial (x >= 0)."); + } + else if(x == 0) + { + return 1; + } + else + { + return x*factorial(x-1); + } +} +``` +Notice that we are able to make use of non trivial language features like branching logic with separate `return` statements and recursion in this definition: these do not conflict with any of our conditions on `constexpr`. + +Now consider the following statements that could appear in main, and whether or not they will compile: + +```cpp +int main() +{ + const int x = 5; + int y = 3; + + constexpr int z = factorial(x); // Compiles okay: x is const + constexpr int r = factorial(y); // Doesn't compile: y is not const + constexpr int p = factorial(-3); // Doesn't compile: throw is reached + int q = factorial(-3); // Compiles okay: throws an exception at runtime + + return 0; +} +``` +Notice that we can only initialise a `constexpr int` variable with a call to `factorial` if the **argument is `const` and the function evaluation does not `throw`**. If we initialise a non-`const` variable using `factorial` then it will be treated as a runtime expression and so these conditions don't apply. + +### Other Uses of Constant Expressions + +Constant expressions are not just useful for run-time optimisation; they also allow use to write more expressive code anywhere where we need to know information at compile time, such as constant template arguments (e.g. the length of an `std::array`) and static memory allocation (e.g. `int x[...];`). + +## Optional: Inspecting Optimised Code at the Assembly Level -### Optional: Dumping Executables and Inspecting Assembly Code +The process of optimisation, and the optimised executable at the end of it, can feel rather confusing and obscure given that we are not directly in control of it. Nevertheless, there are ways to get a better understanding of what the compiler has done to our code if we're willing to look at a lower level! This course is certainly not about assembly or low level programming, but you can understand something about what optimising compilers are doing if you take a look at the end result of your compilation. This can be instructive when working with small examples to better understand what the compiler is doing to your code. diff --git a/03cpp2/sec05SoftwareBuilds.md b/03cpp2/sec05SoftwareBuilds.md deleted file mode 100644 index baff153ed..000000000 --- a/03cpp2/sec05SoftwareBuilds.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Building research software ---- - -Estimated reading time: 25 minutes - -## Introduction - -In an introductory C++ class, you might have worked with small projects containing just a handful of files with C++ source code, and might have compiled and linked them together into an executable program. -As a high-level zeroth order oversimplification, this consists of `compiling` (translating) each C++ source file in your project into a machine-specific `object` file, and then connecting up the symbols in them (`linking`) to resolve each symbol to a unique definition. -At the end of this process, we obtain an executable program that is intended to be run on that particular computer. -It should be noted that the symbol names (e.g. for variables and functions) in the source code are not typically retained in their original form (i.e. they become *mangled*) when processed by this toolchain (unless explicitly instructed to retain them in original internal reference tables). - -If we encounter any error(s) during the compilation or linking phase, we investigate the probable cause in our source files, edit them and repeat the aforesaid process until we successfully obtain an executable program. -When we obtain an executable program, a rudimentary verification step could be to run the program and test its output against expected outputs for *a priori* chosen set of inputs. -When logical flaws (bugs) are discovered in our source files, we edit them to address these flaws (known as `debugging`). -It is typical to adopt this classical *edit-compile-debug* cycle by working at either a command-line interface or through appropriately labelled buttons from an Integrated Development Environment (IDE), which invoke the relevant compilation, linking and debugging software behind the scenes, and present a simplified graphical user interface to the developer. - -## Scalability considerations for large projects - -### Multiple types of executable outputs - -While this above methodology suffices for small projects and enables one to focus on learning and developing a solid understanding of the programming language itself, this approach does not scale well to meet the needs of larger, more complex projects. -For instance, most C++ research projects will need to produce multiple kinds of executables concurrently, e.g. a small, performance-optimised binary that can run as efficient as possible on the target hardware, and a separate executable that contains references to all the original symbols embedded within the executable program (usually for debugging purposes by the developers). - -### Cross-compilation needs - -Secondly, programmers may write code on their laptops which typically have an x86_64 or ARM processor, and may wish to produce executable programs targeting multiple CPU architectures (e.g. different kinds of ARM/MIPS processors and/or advanced x86_64 processors with advanced vector instructions typically found research cluster installations). -This is known as *cross-compilation*. -This means that the compilation/linking process should now involve queries of the underlying hardware to identify and enable hardware-specific capabilities into the final executable(s). - -### Cross-platform support - -The C++ standards (i.e. formal documents specifying the details of the C++ programming language) are agnostic of the target Operating System i.e. in theory at least C++ code written using only the core language and the standard library should be able to run on any OS for which a conforming toolchain exists, and library writers might wish to provide cross-platform support to users irrespective of the OS. - -### External dependencies - -Large scale C++ research projects are dependent on many third-party external libraries that are either pre-installed or are compiled along with the project. -For instance, the finite element library [Deal.II](https://dealii.org/), which won the Wilkinson award in 2007 for numerical software, relies on 37 other core and optional third-party libraries (for meshing, parallelisation, dense and sparse matrices, graph partitioning etc) and each of these dependencies further depend on dozens of other libraries and so on, resulting in a combinatorial explosion. -All high quality scientific software use third-party testing libraries for code verification. -Several scientific libraries run these checks in automated pipelines upon each edit to the source code, which enables larger and geographically distributed developers to contribute high-quality code while trying to avoid known bugs or failures. - - -### Multiple programming languages - -Furthermore, large scale research software tend to use a heterogeneous mix of programming languages. -For instance, the Fenics finite element library consists of several components, with various [performance-critical components](https://github.com/FEniCS/dolfinx) written in C++ and other user-facing components written in domain-specific variants of scripting languages such as Python. -Other research software might contain a mix of Fortran and C++ e.g. if a decades-old scientific project is transitioning from Fortran to C++. -It is increasingly common to have GPU computing enabled (conditionally, if available) in modern massively parallel computational operations which may additionally involve the CUDA language. -The most common form of distributed memory parallelism in high performance computing environments is a library written in plain C. -This heterogeneous nature of research projects imply the correct invocation of multiple toolchains to produce the final executable output. - -The need to invoke multiple toolchains, conditionally enabling various capabilities, that are dependent on hardware inspection, for each platform and OS quickly becomes non-scalable. -Any such executable (or in general any output artefact of the workflow) produced with specific desired properties is considered as a *software build* (or simply a *build*). -It is evident that obtaining software builds through manual invocation of various tools either at the command-line or through basic IDE options quickly becomes cumbersome and tedious. - -### Build automation tools - -#### Makefiles - -The complexities of build tools have been acknowledged since nearly half a century, and several build automation tools (also known as build systems) have been devised over time to tackle the problem. -An early such tool, which is still popular today, is [`make`](https://en.wikipedia.org/wiki/Make_(software)). -In this build system, the library author writes a set of plain-text files, known as MakeFiles, and provides them along with the C++ source code. -For system inspection, a set of companion tools called [GNU Autotools](https://en.wikipedia.org/wiki/GNU_Autotools) was developed to configure the build capabilities depending on the target processor's capabilities. - -While this works quite well (even today) for a large number of scenarios, the `Make` and `Autotools` system have a number of drawbacks by modern yardsticks. -For instance, they are available only in Unix-like OSes. MakeFiles use various cryptic shorthand notations and are frustratingly stubborn about formatting requirements. -The syntax makes is hard to fathom the relationships between various dependencies, and there are no built-in mechanisms to visualise them. -There is no built-in debugger. -In short, it is tedious to write MakeFiles by hand for large projects, but it is a good build system, as long as the required MakeFiles can somehow be generated for us. -Enter build system generators! - -#### Build system generators - -Non-Unix build systems have their own native build systems. -For instance on Windows, with the Microsoft C++ compiler and toolchain (MSVC), the build system is known as Visual Studio (an IDE exclusive to Windows) solutions, and on macOSes, the toolchain provided by Apple produces files in a proprietary format that can be read in by Xcode (an IDE exclusive to macOS) and then compiled to produce the final executable. - -In late 1999/early 2000, there was no compatible build system that worked across all Operating Systems, and developers had to go through the frustrating process of manually invoking multiple toolchains on different systems. -It was during this time that, Kitware Inc, a company that was part of a consortium tasked with designing the C++-based Imaging Toolkit (itk) funded by a US national research grant, developed CMake, a cross-platform *build system generator* (a system that emits output for consumption by native build systems, which in-turn have to be invoked to produce the final build output). - -Since then CMake has continuously evolved and improved, and while there certainly remain some valid criticisms which have spawned numerous other alternatives, it has become the de-facto build system generator of choice in C++ (and heterogeneous language mix) projects, both in academia and in industry. -CMake can be used from the command-line while being able to generate outputs directly consumable by various IDEs. -On the complementary side, a number of modern IDEs have integrated support for CMake projects through their graphical user interfaces. -At present, the majority of scientific packages use CMake. -Most third-party libraries and dependencies provide some mechanism/tooling support for CMake to help configure and build our projects. -Given these considerations, in this course, we shall be adopting CMake for building our C++ projects. diff --git a/03cpp2/sec06CMakeBackground.md b/03cpp2/sec06CMakeBackground.md deleted file mode 100644 index c1cf63941..000000000 --- a/03cpp2/sec06CMakeBackground.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: CMake Background ---- - -Estimated reading time: 35 minutes - -## Introduction to modern CMake - -### CMake versions - -Since its initial release, CMake has continuously evolved and improved. -The developers make regular feature updates to CMake to facilitate modern software development principles. -So, in practice what does version of CMake can be considered as 'modern'? -Version `2.18.12` (which served as the starting point of the `v3.x` series) that appeared nearly 15 years ago, introduced many paradigm shifts, but can no longer be considered modern by today's standards. -However, it is being mentioned here for the clean break it enforced from the 2.x series (much akin to the move to `python 3.x` from `python 2.x`). - -Recall that CMake is a build system generator, and not a build system. -It queries the hardware and environment for the toolchain capabilities to use to generate a build system that invokes these tools with appropriate functionality (i.e. options) enabled. -It is important to **never** use a CMake version **older than the intended compiler/toolchain**. -This is because CMake will not understand the additional functionality that the compiler is capable of. -In this course, we shall be using the **C++17** standard, for our projects. -Nearly all major compiler vendors have implemented nearly complete support for this standard since ~2019. -For instance, the [`gcc`](https://en.wikipedia.org/wiki/GNU_Compiler_Collection) toolchain, ubiquitously available on all Unix-like systems has nearly complete support for C++17 since ~9.x. -Most C++17 features were implemented by the [LLVM/Clang toolchain](https://en.wikipedia.org/wiki/Clang#Status_history) by ~v9, and by Microsoft's C++ toolchain (available for Windows systems) since VS2017 15.x. -The toolchains provided by HPC system vendors such as Intel and Cray have also supported C++17 over the last 2 years on powerful compute clusters. - -Initial C++17 support was added to CMake in version 3.8 (2017) and improved steadily in the next few releases. -This, coupled with improved compiler support for the C++17 standard means that, in principle, any CMake version released in the last 2 years or so shall be more than sufficient for new projects. -CMake facilitates full backwards compatibility i.e. a newer CMake version is able to exactly replicate the behaviour of older versions through a simple user configuration command (which we will cover soon). -Recent versions of CMake continue to gain support for GPUs (with better support for CUDA) and other modern technologies. -Furthermore, the most recent version of CMake is extremely easy to install (even without administrative privileges), and given its ability to easily enforce backward compatibility, there is no reason not to install the latest version (which as of Dec 2022 is `3.25.1`). - -### Installing CMake - -In this course, we shall use the latest available version of CMake. -The VSCode development container provided for the course already has this latest version installed. -However, since we do not invoke the very latest features in the initial weeks of the course, we shall configure it to use a slightly older version for broad compatibility with most platforms and shall help with installing the latest version. - -CMake can be installed through a variety of methods on virtually every platform (processor architecture and OS). -Detailed install instructions shall be made available on the Moodle portal for the course. - -## CMake overview - -As a high level overview, CMake is a build system generator which provides command line tools that read user-provided plaintext files describing the build requirements of the project, and produces files that can be consumed by a native build system for the platform (e.g. Makefiles). -The plaintext files that describe the project's build requirements are written in CMake's custom language and are named `CMakeLists.txt` (the filenames must have exactly identical casing as given here). -It can be considered as yet another programming/scripting language with its own rubrics for variables, functions, macros and other control structures (conditional statements, loops etc). -Owing to the need to cater to a wide variety of platforms, the build requirements are written at a rather high-level describing *what* must be built, and describes the relationships between various dependencies in the projects, and excludes low-level minute details of *how* something should be built. - -CMake is much more comprehensive than what was described here, and provides its own integrated support for not only generating the build system, but also performing the build with great control over various aspects, as well as facilitating testing, installation of software onto standard locations on the user's machine, packaging the build's artifacts for wider distribution on various platforms, and various other functionality. -We recognise the cognitive burden of learning yet another full-fledged language just to describe the project's build requirements! -Hence, we shall strive to introduce the CMake language and tooling piecemeal over the duration of this course. - -### Hierarchical project structure - -Large scale research projects typically use a hierarchical project structure using modular code patterns for [separation of concerns](https://en.wikipedia.org/wiki/Separation_of_concerns). A modular code pattern refers to a software design paradigm wherein each unit of code has well-defined interfaces and one (and only one) specific functionality. The modularity aspect arises from the fact that another code unit with a different/improved algorithm may be swapped in for an existing code unit, whilst retaining the same interfaces and system behaviour. -Here, we discuss an example hierarchical project structure, albeit at a smaller scale that you may adopt suitably, depending on the current needs. - -``` - -./my_app/ -├── CMakeLists.txt -├── LICENSE.txt -├── README.md -├── cmake/ -├── external/ -├── src/ -└── test/ - -``` - -#### Components that do not directly influence the local build - -The project's root primarily contains meta-information (e.g. for human consumption). -For instance, professional projects have a `LICENSE.txt` file that describes the wording of the license that covers the terms and conditions of usage of the code and (if any) derivatives. -The `README.md` file provides a high-level description of the project's aims and purposes, and provides instructions for its usage (e.g. how to build the project, provide user inputs, configuration options, running etc). -For detailed documentation, some scientific projects have a `doc(s)/` folder with the plaintext sources to generated documentation (e.g. rendered as HTML or converted to PDF). -We do not explore this detailed documentation aspect in this course. -Other typical files placed in the project's root are a contribution guide (for open source projects), describing the procedures and practices for accepting code into the project for bug fixes and enhancements. - -Professional projects employ a Version Control System (VCS) such as `git`, for meticulously tracking the changes to project-related files. -The tracked components of the project tree along with components to handle its version history on the local filesystem constitutes the local project *repository*. -The root of the project tree also contains VCS-specific files and folders such as `.git/` and `.gitignore`. -The project root might have configuration files pertaining to tools for automating tasks such as running tests on a remote machine, performing static analysis, ensuring consistent formatting of source code etc. -We shall cover these topics and introduce associated tools in future lessons. - -#### Components that directly influence the local build - -At the root of the project tree, we have a top-level `CMakeLists.txt` file that describes the high-level build details of the overall project. -We describe the basic contents and organisation of this file in the next lesson. -To keep the length of the top-level `CMakeLists.txt` file manageable and for readability purposes, we may optionally choose to split certain boiler-plate project-level CMake helper scripts, functions and other custom CMake-specific files in the `cmake/` directory. - -The `external/` folder is intended for code and data from external sources e.g. a third-party library, that our project depends on, and we provide appropriate instructions to CMake on how to incorporate that in building our project. -Note, in particular, that there are no source files at the root of the project repository. -The build process itself could produce/create other (ephemeral or otherwise) generated files and folders. -Such build artefacts are not considered as part of the core project files, and are configured to be ignored in version control systems. -All project-specific source files are located inside the `src/` directory and codes for testing the programs' logic are placed in the `test` directory. -We shall present the details of the `src/` tree next, and defer discussing the `test/` tree until later (when we introduce unit testing). - -##### The `src/` tree - -An exploded view of a typical source tree can be as follows: - -``` - -./my_app/src/ -├── CMakeLists.txt -├── include/ -│   ├── common_libheader.h -│   └── my_app.h -├── lib1/ -│   ├── CMakeLists.txt -│   ├── include/ -│   │   └── header_lib1.h -│   └── src_lib1.cpp -├── lib2/ -│   ├── CMakeLists.txt -│   ├── include/ -│   │   └── header_lib2.h -│   └── src_lib2.h -└── my_app.cpp - -``` - -In this `src/` project structure, the `my_app.cpp` contains the `main()` entry point function of our C++ project. -We assume that we are modularising our overall code by grouping related functionality into two libraries `lib1` and `lib2` that provide non-overlapping/independent features that can be reused elsewhere within the project (e.g. in `my_app.cpp`). -The library-specific header files live within the nested `include/` directories of each of their respective folders, while common header files that both libraries and the main executable may use are kept in `src/include`. -The `CMakeLists.txt` files located within each library's directory describes the building of that library alone. -The `CMakeLists.txt` within `src/` describes how to build `my_app` from the corresponding sources, and how to link up the two internal libraries and any external third party libraries (from `../external`) to produce the final executable. - -Such a modular project structure, and the correspondingly nested `CMakeLists.txt` that reflects this hierarchy, help in clean separation of functionality into distinct pieces, and aids in the comprehension of dependencies in large projects, as well as facilitating the testing and debugging of such small well-defined project modules. -While many variations of the aforesaid project structure exist, it is generally acknowledged to use such a hierarchical/modular project structure which results in simpler build descriptions using CMake. In the next lesson, we start writing our own `CMakeLists.txt` for a simple project. diff --git a/03cpp2/sec07CMakeHelloWorld.md b/03cpp2/sec07CMakeHelloWorld.md deleted file mode 100644 index 8a72db43a..000000000 --- a/03cpp2/sec07CMakeHelloWorld.md +++ /dev/null @@ -1,237 +0,0 @@ ---- -title: HelloWorld with CMake ---- - -Estimated reading time: 40 minutes - -# Building a simple C++ project with CMake - -## Introduction - -Having grasped the background and motivation for adopting the CMake build system generator, let us get our hands dirty and do a hands-on exercise in building a simple C++ project with CMake. -This will be CMake's equivalent of the classic hello world project, wherein we build an executable from C++ source code that prints ... well, "Hello World!" (what else?) to the screen. - -We will perform this as the very first exercise during Week 1 of the class. - -## Project Structure - -To help focus on the new CMake-related aspects, we shall use a somewhat simplified version of the hierarchical project structure presented in the [previous lesson](./sec03CMakeBackground.md). - -For this exercise, the following project structure is given: - -``` -./CMakeHelloWorld -├── LICENSE.txt -├── README.md -└── src - └── hello.cpp - -``` - -The `hello.cpp` file inside the `src/` folder contains a simple C++ source code that prints "Hello World!" to the console. - -``` cpp - -#include - -int main() { - std::cout << "Hello World!" << '\n'; - - return 0; -} - -``` - -CMake processes user-provided configuration files that are named `CMakeLists.txt` (known as listfiles) in which we describe our build requirements at a high level. -Our task is to create and populate one or more `CMakeLists.txt` files that reflects our project's modular/hierarchical organisation. -The modern best practice is to always have a top-level `CMakeLists.txt` file at the project root, with additional, separate nested/hierarchical `CMakeLists.txt` files for each individual unit of logic/functionality i.e. resembling our project's file organisation. - -Therefore, in this case, we shall use the following structure: - -``` - -CMakeHelloWorld/ -├── CMakeLists.txt -├── LICENSE.txt -├── README.md -└── src - ├── CMakeLists.txt - └── hello.cpp - -``` - -### Populating the top-level (root) `CMakeLists.txt` - -#### `cmake_minimum_required()` - -`CMakeLists.txt` files are plain-text files written in the CMake-specific custom language syntax. -For every project, the top most `CMakeLists.txt` must start by specifying a minimum CMake version using the [`cmake_minimum_required()`](https://cmake.org/cmake/help/latest/command/cmake_minimum_required.html) command. -Although upper, lower and mixed case commands are supported by CMake, the modern trend is to use lower case commands. -Yet another thing to note is that, like C++ itself, `CMake` is whitespace-agnostic while processing `CMakeLists.txt` files. - -Therefore, we start off with the following: -``` cmake -cmake_minimum_required(VERSION 3.25) -``` - -Even the most recent versions of CMake installations can replicate the exact behaviour of older releases. -In this example, the minimum `VERSION` is specified, and other options such as the maximum version the project is tested with have been omitted. - -Owing to its long development history and strong emphasis on retaining backward compatibility, most CMake commands have multiple variants and sometimes have an overwhelming array of options. -Naturally, it is not feasible to cover every aspect of the language here. -However, we shall skim just enough of the language here to address our needs, and developers typically acquire proficiency in the language gradually over the years. -Therefore, we encourage you not to get disheartened by the slightly verbose documentation at the outset. - -The main caveat regarding CMake's version used here is that the chosen minimum version should at least support C++17 language standards and compiler options. -Recent versions of CMake have improved package detection, simplified dependency handling. -While an older version suffices for this simple HelloWorld example, it does not hurt to use a CMake version as recent as possible. - -#### `project()` - -Next, we use the `project()` command to set the project name. -This is required with every project and should be called soon after `cmake_minimum_required()`. -This command can also be used to specify other project level information such as the language or version number. - -``` cmake -project(hello_world - VERSION 0.0.1 - LANGUAGES CXX -) -``` - -The first (required) argument to this command is the project's name. -The name of the project can be any valid string. -It does not have to be the same string as the name of the main executable program to be built (although it is conventional/convenient to use the same string for them). -The VERSION field is optional, but is conventional to populate it. -Next, we specify the programming languages that the project uses. -The keyword for C++ projects is `CXX`. -It is helpful to specify the list of languages used in the project, so that cmake does not spend time inspecting the system for the presence of the toolchains for every supported language (CUDA, fortran, C, Java etc). - -#### Setting global options at the project root with `set()` - -It was common practice to make liberal use of the [`set()`](https://cmake.org/cmake/help/latest/command/set.html) command to define various built-in, user-defined and environment variables at the top level `CMakeLists.txt`. -This includes setting up the C++ language standard, compiler flags etc. -While it can be useful to do so, CMake provides a much more fine-grained control to set options for each logical/modular unit in our project, and we have a slight preference towards this approach. - -Nevertheless, the top level `CMakeLists.txt` file can be used to set various global conditions applicable to the whole project. -When CMake configures a project, the build tree closely resembles the source tree hierarchy in the build tree. -The path to the root of the build directory is available as the built-in variable [`CMAKE_BINARY_DIR`](https://cmake.org/cmake/help/latest/variable/CMAKE_BINARY_DIR.html) whose value can be accessed within `CMakeLists.txt` by dereferencing it using the syntax `${VAR_NAME}`, in this case `${CMAKE_BINARY_DIR}`. -However, a common practice for implementing a logical separation is to have CMake place the generated executables within a separate `bin/` subfolder at the root of the build directory. -This can be achieved by setting the value of the [`CMAKE_RUNTIME_OUTPUT_DIRECTORY`](https://cmake.org/cmake/help/latest/variable/CMAKE_RUNTIME_OUTPUT_DIRECTORY.html) as follows - -``` cmake -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) -``` - -#### Parsing nested `CMakeLists.txt` within the `src/` directory - -Until now, we have set certain high-level description of the project, but have not provided instructions on building the "hello_world" executable program from the source! -This is intentional. -The task of describing the build of each logical/modular project unit is delegated to nested/leaf `CMakeLists.txt` that reflects the project's hierarchy. -In this case, we wish to 'hand-off' control to `src/CMakeLists.txt` for building the `hello_world` executable. -This is achieved by using the [`add_subdirectory()`](https://cmake.org/cmake/help/latest/command/add_subdirectory.html) command. - -``` cmake -add_subdirectory(src) -``` - -In the above line, we instruct CMake to process the `CMakeLists.txt` located within the `src/` directory. -These list files can be nested quite deeply, and at each depth level, we can have as many 'sibling' folders with one `CMakeLists.txt` per folder that describes the logical program unit to be built. - -After processing all nested list files, control returns to the top level `CMakeLists.txt`. -Since the above line was the last in this file, the configuration process is completed. -We now focus on the build details that is to be described in `src/CMakeLists.txt`. - -### Contents of `src/CMakeLists.txt` - -Within this 'leaf' `CMakeLists.txt` file, we describe how the `hello_world` executable is to be built. -This is considered best practice, i.e. to have a `CMakeLists.txt` as close to the relevant source files for each modular functionality within the project. -This file can see and use all the properties, variables, functions and macros defined at any `CMakeLists.txt` in its parent scope (in this case, all definitions from the top-level `CMakeLists.txt` file are visible here i.e. they are in the 'scope' of this leaf file). - -#### Targets and usage requirements - -We like CMake to build for us an executable called 'hello_world'. -In this case, `hello_world` is a CMake 'target' (or a CMake 'executable target' to be precise). -Targets (not variables) are the first-class citizens in modern CMake language. -Hence, the relationships between various constituents (i.e. executables, source files, header files, internal libraries, other third-party code/files, system dependencies etc.) is to be described in a target-centric way within each `CMakeLists.txt` file. - -The behaviour of targets, their features and their various properties can be set in three ways: -- only on/for/affecting itself (using the PRIVATE keyword) -- only on/for/affecting other targets that consume this current target (using the INTERFACE keyword) -- on/for/affecting itself as well as other targets that consume this current target (using the PUBLIC keyword) - -We recognise that this is quite a confusing aspect for newcomers to CMake, but is one of the key things to understand how targets and their dependencies interact for a given build. -The only way to get better comprehend this critical component is by practicing these ideas during the first few weeks of the course. - -The following matrix may help slightly, and we may choose to refer to this at times throughout the course: - -Keyword selection table (Who needs/uses a dependency?) - -| | Other (consuming targets) | -|----------------|------------|---------| -| **Current target** | **YES** | **NO** | -| **YES** | PUBLIC | PRIVATE | -| **NO** | INTERFACE | N/A | - -#### The `add_executable()` command - -We use the [`add_executable()`](https://cmake.org/cmake/help/latest/command/add_executable.html) command to declare that we'd like CMake to build an executable program called 'hello_world' (executable target in CMake parlance). - -``` cmake -add_executable(hello_world) -``` - -#### The `target_sources()` command - -Next, we tell CMake that the executable target `hello_world` depends on one C++ source file, viz `hello.cpp`. -This is done via the [`target_sources()`](https://cmake.org/cmake/help/latest/command/target_sources.html) command. - -``` cmake -target_sources(hello_world PRIVATE hello.cpp) - -``` - -Referring to the usage requirements table, the keyword `PRIVATE` conveys to CMake that the file `hello.cpp` serves as a dependency only for the current target `hello_world`, and is not propagated to any other targets that depend on `hello_world`. -This is not much relevant here, since the `hello_world` target is simply an executable program which does not depend on the file `hello.cpp` after it has been built (i.e. at run-time). -In such circumstances, the keyword can be omitted. -However, the usage requirement keywords become crucial (and sometimes mandatory) in describing more intricate dependency graphs involving various libraries as projects evolve in complexity over time. -Hence, we strongly encourage students not to omit this keyword and think about the usage requirement for every target property/behaviour set in `CMakeLists.txt`. - -#### Setting the compile time properties of targets - -We wish to ensure that the `hello_world` target conforms to the C++17 standard. -One way to achieve this in CMake is through the [`target_compile_features()`](https://cmake.org/cmake/help/latest/command/target_compile_features.html) command which can be used to set some pre-defined properties/behaviour/characteristics to follow for compiling targets. - -We add the following line: - -``` cmake -target_compile_features(hello_world PUBLIC cxx_std_17) - -``` - -Here the keyword `cxx_std_17` keyword gently suggests CMake to invoke a C++17 compatible compiler if detected on the target system. -One of PRIVATE or PUBLIC keyword is required here (the INTERFACE keyword does not directly apply to executable targets). -We may use either here, but we chose PUBLIC to be compatible with recommended best practices for new projects. -In the future, if we wish to split our project into a library the PUBLIC keyword will ensure that our library as well as any other executables or other libraries that depend on this shall acquire C++17 capabilities. - -#### Setting individual properties on targets - -While `target_compile_features()` allows us to select a list of high-level behaviour, an alternate is to set individual properties/flags on each target through some pre-defined CMake variables. -This is achieved using the [`set_target_properties()`](https://cmake.org/cmake/help/latest/command/set_target_properties.html) command. - -While `target_compile_features(hello_world PUBLIC cxx_std_17)` encourages the use of C++17 standards, it does not enforce this. -The built-in boolean [`CXX_STANDARD_REQUIRED`](https://cmake.org/cmake/help/latest/prop_tgt/CXX_STANDARD_REQUIRED.html) property can be set to `True/ON` to enforce this. -Another useful thing to do to ensure wide platform and target portability of our project is to disable compiler extensions. -Various C++ compilers take liberties to enable compiler-specific extensions by default unless explicitly directed not to do so. -The built-in boolean property [`CXX_EXTENSIONS`](https://cmake.org/cmake/help/latest/prop_tgt/CXX_EXTENSIONS.html) can be set to `False/Off` to disable these compiler extensions. -The `set_target_properties()` command allows us to set multiple properties on targets within a single invocation like so: - -``` cmake -set_target_properties(hello_world PROPERTIES CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF) - -``` - -A starter template/scaffolding for the project has been provided with relevant prompts for filling in the required CMake commands in the two `CMakeLists.txt` files. -We shall perform this during the first class. -We have now fully described our build, and are now ready to proceed to perform the build for our project, all whilst conforming to the desired C++ standard and implementing CMake best practices! -The procedure to build and run the executable is described in the next lesson. diff --git a/03cpp2/sec08BuildHelloWorld.md b/03cpp2/sec08BuildHelloWorld.md deleted file mode 100644 index d4fc1d433..000000000 --- a/03cpp2/sec08BuildHelloWorld.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -title: Building 'HelloWorld' ---- - -Estimated reading time: 45 minutes - -# Building and running the 'hello_world' project - -### 'Out-of-Source' vs 'In-source' builds - -The project is now ready to be built. -However, there is one final aspect that needs to be discussed before we proceed with the build viz. the location of the build artefacts (intermediate/final outputs generated by the build process). - -It is possible to invoke CMake at the project root without specifying a directory intended to hold build artefacts. -This is known as an *in-source* build. -While CMake does not explicitly forbid this (it can be coaxed into not allowing this), it is frowned upon to do so. -CMake is a build system *generator* and its output is intended to be consumed by a native build system such as `make`. -The generation and build process may produce a **lot** of artefacts, a real hodgepodge of replicated source files, intermediate build system files, binary blobs etc. - -The clean project repository that we started with shall be left in a polluted state of mess with all such extraneous output strewn all over the place starting at the root of the project's filesystem. -Worse, these extraneous files scattered throughout shall make it harder for us to ignore them in version control systems. -Finally, this will severely hamper our ability to have multiple types of builds (e.g. Debug vs Release), since each build will overwrite the generated files, and some types of builds produce artefacts not applicable to other build types, leaving the user in confusion. -In an *out-of-source* build, the build tree is separate to the source tree. -All the build-related files shall be located within that designated directory, which can easily be ignored in version control systems, and deleted when no longer needed. -Multiple build types can easily be accomplished by nesting under a common build directory. -Hence, projects **must** always use *out-of-source* builds. - -### Build process - -#### Configure stage - -Invoke the executable `cmake` at the root of the project, specifying a build directory as follows. - -``` bash -$ cmake -S . -B build_dir -``` - -This instructs `cmake` that the top level `CMakeLists.txt` (the source folder indicated by the `-S` flag) is in the current folder (referenced by `.`), and that the build artefacts need to be located within a directory named `build_dir` (which is passed as argument to the `-B` flag). -CMake shall create the build directory if it does not exist already. -If the directory exists, it simply reuses it. -This step is known as the CMake `configure` stage during which CMake merely generates the build system for the target platform. -Syntax errors or other errors pertaining to incorrectly describing the build shall be flagged here, interrupting the configure process. - -During the configure stage, CMake queries the system and environment to infer the languages, libraries and toolchains to be invoked within the build system. -If not explicitly specified through a CMake predefined variable [`CMAKE_BUILD_TYPE`](https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html), the default build is a "non-release, non debug" empty build type, which is acceptable for now. -When CMake completes the configure stage, we are ready to build our project. - -#### Build stage -At the end of the configure step, we obtain a platform-native build system which can be invoked directly to perform the build. -However, this requires us to move into the executable directory (which may be deeply nested if multiple build types are organised under the same build root). -We can stay at the root of the project repository, and invoke the build through CMake itself as follows: - -``` bash -$ cmake --build build_dir -``` - -This will build a target called `all` which is a special target provided by CMake to build all build artefacts. -In this case, we have only one target, an executable named `hello_world`. -If we have multiple targets and if building `all` takes a long time for large-scale projects, we can build just the target of interest instead, as follows: - -``` bash -$ cmake --build build_dir --target hello_world -``` - -The `--target` flag may be abbreviated to `-t`. - -The build process invokes the toolchain (typically the compiler and linker) with the requisite flags, and produces the project's desired outputs (typically libraries and executables). -If the build process was successful, we now have an `hello_world` executable that can be run. -Let's do that next! - -#### Invoking the build outputs - -Somewhat frustratingly, the final build outputs are typically deeply embedded in the build directory tree, resulting in very long absolute paths. -This is because CMake project outputs are not typically designed to be run directly from the build tree. -Libraries and executables produced by such projects are typically intended for downstream consumption. -CMake provides detailed facilities to package up a project's outputs for installation into standard locations on the native platform, as well as package them up for wider distribution. - -To follow the classical *edit-build-debug* cycle, it is of course, necessary to run the executable program to verify its behaviour. -Some IDEs have provided abilities to invoke the final build outputs through their graphical user interfaces that are conditionally activated by parsing CMake projects. -We have ameliorated this problem a bit by specifying in our top level `CMakeLists.txt` that the final output shall reside in the `bin/` directory at the root of the build tree. -Thus, at the command line, from the root of the project we can invoke: - -``` bash -$ ./build_tree/bin/hello_world - -``` - -At this point, we should see "Hello World!" printed to our console. -Whew, success! - -#### Optional: Easy invocation of built executable through CMake - -CMake allows us to build custom targets that allows us to run any available commands in the user's path using the [`add_custom_target()`](https://cmake.org/cmake/help/latest/command/add_custom_target.html) command. -We may set up a custom target called `run_program` within the `src/CMakeLists.txt` file as follows: - -``` cmake - -add_custom_target(run_program - ALL - COMMAND "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/hello_world" - DEPENDS hello_world # name of the target this depends on - COMMENT "Run the built executable") - -``` - -By default, custom targets are not built unless we explicitly ask CMake for it. -The optional keyword `ALL` ensures that this `run_program` target will be built when the default pseudo-target `all` is built. - -This custom target invokes a command by providing the path to the `hello_world` executable that can be easily accessed by dereferencing the `CMAKE_RUNTIME_OUTPUT_DIRECTORY` (which we configured in the top-level `CMakeLists.txt` file). -More crucially, this target lists the `hello_world` target as a dependency using the DEPENDS keyword. -Thus, building this `run_program` target implies that the target `hello_world` must be up to date. -If the `hello_world` executable is not present, then CMake will first build the target, and then run our command. -Neat! - -Thus, we can directly run the output immediately after the configure stage by simply building the default target as follows: - -``` bash - -$ cmake --build build_dir - -``` - -This will build and run `hello_world`, resulting in "Hello World!" being printed on the console! - - -### Rebuilds in the event of source changes - -One of the biggest advantages of using a build system generator like CMake is the easiness with which the project can be rebuilt in the event of changes to sources. - -(Exercise) If the file `hello.cpp` is modified, we can skip the configure stage and simply proceed to the build stage with the usual invocation: - -``` sh -$ cmake --build build_dir -``` -and the new executable will be built to correspond to the updated `hello.cpp` file. - -If any of the `CMakeLists.txt` files are modified, then directly invoking the build step shall automatically trigger the configuration stage, and the resulting executable accounts for all such changes. -This is because, when CMake initially generates the native build system, it implicitly declares the `CMakeLists.txt` files themselves as dependencies to the built executable. - -(Exercise) Try adding a custom header file to `hello.cpp` and declare it as a dependency of the `hello_world` executable using the [`target_include_directories()`](https://cmake.org/cmake/help/latest/command/target_include_directories.html) command. - -We can now reap the rewards of having a hierarchically organised project structure, with modular separation of project functionality in source code, together with a properly configured build system. -This upfront effort helps the project to be flexible enough to evolve with minimal scalability challenges to tackle future needs. - - - diff --git a/05libraries/index.md b/05libraries/index.md index 3148dc35b..73a8311c1 100644 --- a/05libraries/index.md +++ b/05libraries/index.md @@ -7,5 +7,15 @@ title: "Week 5: Code Design and Programming Paradigms" This week we will consider code design in C++ in a more general sense. We'll encounter some common coding patterns that appear frequently in object oriented code, explore some of the different programming paradigms that exist and how they have influenced C++, and work towards writing good quality code. 1. [Designing Classes](sec01DesigningClasses.html) + - Code quality + - Resource Acquisition is Initialisation (RAII) + - Design patterns 2. [Programming Paradigms](ProgrammingParadigms.html) -3. [Code Design](sec03CppCodeDesign.html) + - Imperative Programming + - Object Oriented Programming + - Functional Programming in C++ +3. [Error Handling](./sec02ErrorHandling.md) + - Exceptions + - Error codes + - `std::optional` +4. [Code Design Summary and Guidance](sec03CppCodeDesign.html) \ No newline at end of file diff --git a/03cpp2/sec01Exceptions.md b/05libraries/sec02ErrorHandling.md similarity index 71% rename from 03cpp2/sec01Exceptions.md rename to 05libraries/sec02ErrorHandling.md index 569eaba8e..08671f8e3 100644 --- a/03cpp2/sec01Exceptions.md +++ b/05libraries/sec02ErrorHandling.md @@ -1,12 +1,10 @@ --- -title: Exceptions +title: Error Handling --- -Estimated Reading Time: 40 minutes - # Exceptions -Exceptions are used for error handling in C++ (and many other languages). The standard library provides a variety of exceptions that we can use straight away, although we can also write our own exceptions if we want additional functionality. +Exceptions are the primary mechanism used for error handling in C++ (and many other languages). The standard library provides a variety of exceptions that we can use straight away, although we can also write our own exceptions if we want additional functionality. The `` header contains the following exception types: @@ -273,3 +271,151 @@ We've discussed above that raising an exception will prematurely halt the execut - If you place a try block around a function which may throw exceptions at multiple points (it may have multiple `throw` statements or make calls to a number of other functions which could themselves throw exceptions) and you are passing variables in by reference to be modified, you should be aware of the possible states that your data could be if the function is prematurely halted. Not all the changes that your function is intended to make on your data may have happened! - When we reach the catch block, the stack memory for any functions which threw exceptions and were halted is freed (since they are now out of scope). This means that stack variables are cleaned up, and destructors for any stack variables are called (including smart pointers, the destructors for which de-allocate the data to which they point). Be aware though that if there are _raw pointers_ on the stack, the memory that they point to is not deleted (only the pointer itself is) and so if the memory that it points to is not a stack variable or also owned by a smart pointer a memory leak will occur. **This is one of the reasons why we should not use raw pointers for memory ownership.** If you do have an owning raw pointer in a function and you want to throw and exception, it is vital that you use `delete` to free the memory before throwing the exception; likewise you must be aware of any function calls that you make which could themselves throw an exception: these _must_ be caught so that you can free the memory before returning control to the call stack. + + +# Other Error Mechanisms + +There are some other ways of handling errors that are worth being aware of outside of using exceptions. In this section we discuss _return codes_, which are particularly common in C-based external libraries and legacy code from earlier versions of C++, and `std::optional`, a special type which can represent the absence of a value. + +## Return Codes + +Return codes are common in programming languages like C which do not have exceptions, and so will be frequently encountered by C++ programmers when using libraries built in C. (C is a very common languages library because C has become the lingua franca of programming languages, with most popular languages having a way to interface with C code.) + +The return codes approach can also be useful in its own right in C++, when we don't want to interrupt the program flow in the same way, or when errors are common and the exception overhead starts to become high. High frequencies of exceptions can cause particular problems for multi-threaded programming (you can read about [an example here](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p2544r0.html) if you are interested). + +In a return-code approach, a function returns a value indicating whether it succeeded. One common convention uses an integer, with `0` indicating success and other values identifying different errors. The caller must check the result before continuing. If the function also needs to produce data, a common approach is to place that data in an output argument passed by reference or pointer. Always check the library's documentation, since conventions differ. + +Consider this example for getting the first element of a vector (which may be empty): +```cpp +#include +#include + +using std::vector; + +int head(const vector& v, int &x) +{ + if(v.empty()) + { + return 1; // error code + } + else + { + x = v[0]; + return 0; // success code + } +} + +int main() +{ + vector v{5, 9, 4, 3}; + int x; + if(head(v, x) != 0) + { + std::cout << "It was empty." << std::endl; + } + else + { + std::cout << x << std::endl; + } + + return 0; +} +``` +- In this approach we have to declare our variable to hold the value first, and then pass it into the function. +- We need to check the output of the function to see if it is successful. Unlike an exception, if we need to handle it further up the call stack then we have to check it separately **at every level until we reach the calling scope where we can handle it**. If functions are nested, then every intermediate function between where the error occurs and where it is handled needs to check for an error, and then return early with its own error code to be checked by the function that called it. This means that we often end up with a lot more error checking code using this approach. +- Assigning appropriate error codes can be tricky, and you need to remember what they all mean. Using an accessible namespace to declare some `const` variables to give meaningful names to different error codes can be useful here, but beware that library code that you interface with will use their own conventions! + + +## `std::optional` + +Some functions might make most sense to conceptualise as returning either a _value_ or _nothing_. Examples might be getting the first element of a list (either the first element of a list or nothing if the list is empty), or looking up a value in a key-value map (return the value if the key is in the map or nothing if it isn't). These can be handled using exceptions or error codes of course, but a special _nothing_ value can be useful sometimes to explicitly represent this value. +- Having a special _nothing_ value can help to avoid unitialised variables. This can happen for example when we want to assign a new variable from some function which may fail. +- A _nothing_ value can also be very useful for class design. For example, if you have a class representing some student data, we could set a grade for a course to be either a _value_ (the mark awarded) or _nothing_ (no mark is given yet). This avoids potentially misleading data using default values. + - It can also be very useful for avoiding null pointers when objects need to point to other objects, but we'll discuss pointers next week! +- A _nothing_ value can be propagated through the rest of your calculations like any other value. If your code properly handles the _nothing_ values, this can sometimes simplify the control flow of your program by allowing all your data to go through the same pipeline. + +In C++ this approach can be handled using `std::optional` (in C++17 onwards); it is similar to the concept of the _Maybe monad_ in Haskell and a number of other similar structures in various languages. + +Like `vector`, `std::optional` uses angle brackets to declare the type of value that it can hold. `std::optional` can either hold a value, or the special value `std::nullopt`. + +The following code uses `std::optional` to get the first element (or "head") of a `vector`. +```cpp +#include +#include +#include + +using std::vector; +using std::optional; +using std::nullopt; + +optional head(const vector &v) +{ + if (v.empty()) + { + return nullopt; + } + return v[0]; +} + + +// This function defines the << operator for streaming an std::optional +// to an output stream such as std::cout. +std::ostream& operator<<(std::ostream &os, optional x) +{ + if(x) // this is defined as: "if x has a value" + { + os << x.value(); + } + else // otherwise it must be nullopt + { + os << "nothing"; + } + return os; +} + +int main() +{ + vector v1{5,9,4,3}; + optional x = head(v1); + + vector v2; + optional y = head(v2); + + std::cout << x << ", " << y << std::endl; + + return 0; +} +``` +The output for this program will be +``` +5, nothing +``` + +## Using types to forbid errors + +It is sometimes possible to exclude the possibility of certain kinds of errors by making use of the type system. + +Take for example the factorial function, which is only defined for $n >= 0$. One approach is to accept general integers and check for errors: +```cpp +int factorial(int n) +{ + if(n < 0) + { + throw std::logic_error("Factorial is undefined for n < 0"); + } + ... +} +``` +but we can eliminate this possibility entirely by using an unsigned integer type such as `uint` which is always greater than 0: +```cpp +uint factorial(uint n) +{ + ... +} +``` +Sometimes it is necessary (or, at least, more convenient) for us to have a function like `factorial` which takes an `int` rather than `uint` and checks for errors. We may need to pass in a variable that we can't guarantee is non-negative or the variable needs to hold different values at different times including negative values. However, where possible and reasonable, it's a good idea to use restrictive types to enforce properties that you want. + +- `uint` and `size_t` are unsigned types that can't be less than 0, so can be useful for mathematical functions which take non-negative integers. +- `std::array` variables have their size determined at compile time, so there is no need to check their length as you do with `std::vector`. + +Defining our own classes can also help us to avoid errors. If you write your class' internal logic so that certain properties are always true, then you don't have to check for them in the functions that you pass those objects to. Remember to test those classes thoroughly though! \ No newline at end of file diff --git a/05libraries/sec03CppCodeDesign.md b/05libraries/sec03CppCodeDesign.md index ea6cb0b3a..1d71aef8a 100644 --- a/05libraries/sec03CppCodeDesign.md +++ b/05libraries/sec03CppCodeDesign.md @@ -49,6 +49,24 @@ Inheritance is sometimes misused by C++ programmers to share functionality betwe - Multiple inheritance is generally limited to implementing two distinct, usually abstract, interfaces. An example of multiple inheritance in the C++ standard library is `iostream` (input/output stream) inherits from `istream` (input stream) and `ostream` (output stream). (See [`iostream` documentation](https://cplusplus.com/reference/istream/iostream/) and [C++ core guidelines on multiple inheritance](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rh-mi-interface).) - Inheritance is good at defining what a class _is_, but you can use composition for things that your class _makes use of_. +## When To Start Optimising Code + +When faced with writing code that one knows needs to be efficient, it is common to try to optimise every piece of code written as one goes along. This can lead to a very slow development process, as well as difficult to understand software! There are some key points to bear in mind when you are developing efficient code: + +- Optimisation often takes significant work: it is much slower to write a highly optimised implementation than a straight-forward one. +- Optimised code can become unintuitive and therefore harder to understand for other developers. This also makes maintenance harder, especially if it is not obvious what can and can't be changed without breaking the optimisation benefits! +- Highly optimised code may not be portable, as code may be optimised for specific platforms, architectures, or even individual machines. +- KISS (Keep It Simple, Stupid) is a good principle for first drafts: focus on writing _correct_, understandable code first. +- Measure performance to find your bottlenecks: don't waste time optimising parts of your program that only make up a tiny fraction of your runtime (or memory). A substantial fraction of your runtime is often concentrated in a small part of the program which can be effectively optimised. **Profile your program and prioritise high-impact optimisations first.** +- Measurements should use representative inputs and problem sizes. Measure a build with compiler optimisation enabled: otherwise you may spend time fixing costs the compiler would already remove. +- Continue measuring performance to check that you are making an impact on your runtime / memory usage. +- Monitor your optimised algorithms for **accuracy and precision**. Some algorithms may produce more reliable results than others, especially when using floating point arithmetic. A significant consideration with numerical algorithms is not just the time and space usage, but the numerical stability of the algorithm. + - For example some methods use single (or even half) precision floating point numbers instead of doubles in order to get faster results. This sometimes happens in some statistical methods such as MCMC (Markov Chain Monte Carlo) where we want to be able to sample a random distribution and the additional precision doesn't have a strong impact on the overall statistical properties. + - Sometimes the accuracy requirements justify using more time and memory. For example, a numerically sensitive calculation may require higher-precision arithmetic or a more stable algorithm. Increasing precision can help, but does not automatically make an unsuitable numerical method reliable. +- Stop when the software meets its requirements, or when further improvements no longer justify the development and maintenance effort. + +**Optimisation is usually a trade off between different factors. You should always bear in mind your priorities when creating efficient software.** + ## Undefined Behaviour A quirk of the C++ programming language is that not all source code that compiles is actually a valid C++ program. **Undefined behaviour** refers to situations in C++ where the standard offers no guidance and a compiler can more or less do what it likes; as a result we as programmers may have little idea what will happen if such a program is run, and the results will vary from compiler to compiler, and system to system. This means if our program has undefined behaviour then even if we have thoroughly tested it on our own system, it may not be portable to anyone else's. diff --git a/06tooling/index.md b/06tooling/index.md index 2985605f7..d62fe7ea2 100644 --- a/06tooling/index.md +++ b/06tooling/index.md @@ -6,8 +6,10 @@ This week we'll learn about some of the tools that we can use to improve our cod You should look over and install the following tools, and familiarise yourself a little with the timing statements available in C++. -1. [Timing](sec00TimingAndTooling.html) -2. [Tooling](StyleSafetyTooling.html) +1. [Introduction to Libraries](./sec02LibraryBasics.md) +2. [Linking Libraries](./sec03LinkingLibraries.md) +3. [Installing Libraries](./sec04InstallingLibraries.md) +4. [Useful C++ Tools](StyleSafetyTooling.html) ## Why use Libraries? diff --git a/06tooling/sec02LibraryBasics.md b/06tooling/sec02LibraryBasics.md index f6c8f3ed7..459a46f4a 100644 --- a/06tooling/sec02LibraryBasics.md +++ b/06tooling/sec02LibraryBasics.md @@ -30,10 +30,7 @@ Though normally these steps are invoked by a single command, you can run them on g++ -o ``` -Directly using the compiler without a build tool (e.g., [CMake][lesson-cmake]) will eventually become too difficult and cause a mess, so we use CMake for larger projects! - - -You can find out more about preprocessor directives in [The C Preprocessor][CppAdv6] chapter on the [C++: Advanced Topics][CppAdv] course. +Directly using the compiler without a build tool will eventually become too difficult and cause a mess, so we use CMake for larger projects! ### Including libraries @@ -68,7 +65,7 @@ When you use a library, keep in mind the following questions: * Where is it? (do we need to set `-I` and `-L` when compiling?) - Depending on your compiler, there are some standard locations where it will look for [includes](https://gcc.gnu.org/onlinedocs/gcc-4.9.4/cpp/Search-Path.html) or libraries (using `/usr/lib...` or `/usr/local/lib...`), but other locations may need to be provided using `-I` or `-L` flags to let it know where to look. -* Is it a [Header only][header-only-wiki]? +* Is it a header only? * What about bugs? How do I upgrade? Do I need to build it myself? ### Compilation Issues @@ -101,12 +98,4 @@ string readJson(const string& filename) { Here, we've just written a example `readJson` function that reads some JSON from a file, which we use in our own code instead of calling the library directly. If we ever need to change the library, we can simply change the code inside this function instead of every place we read a JSON file. Wrappers can be simple functions like this, or whole classes that do more than just provide a new interface. Of course, wrappers are not free; they're more code to write, test, document and maintain, but they can help protect you from future changes to libraries. - - -[CppAdv]: https://www.linkedin.com/learning/c-plus-plus-advanced-topics/ -[CppAdv6]: https://www.linkedin.com/learning/c-plus-plus-advanced-topics/about-the-preprocessor -[lesson-cmake]: ../01projects/sec04CMakeHelloWorld.html -[Cherno]: https://www.youtube.com/channel/UCQ-W1KE9EYfdxhL6S4twUNw -[lesson-DynVsSt]: ./sec02LinkingLibraries.html -[header-only-wiki]: https://en.wikipedia.org/wiki/Header-only -[learncpp-static-dynamic]: http://www.learncpp.com/cpp-tutorial/a1-static-and-dynamic-libraries/ + \ No newline at end of file diff --git a/07performance/sec00Motivation.md b/07performance/sec00Motivation.md deleted file mode 100644 index 2b9552314..000000000 --- a/07performance/sec00Motivation.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Why Optimise for Performance? ---- - -Estimated Reading Time: 10 minutes - -# Performance Optimisation Considerations - -## What Should You Optimise and Why? - -Performance optimisation has always been a significant concern in scientific computing, although the motivations, and targets of optimisation, can vary. In principle we usually think about optimisation of two different kinds: - -- Time: we want to optimise algorithms for speed to get the same calculation done more quickly. -- Space: we want to optimise algorithms for memory usage to get the same calculation done with lower memory requirements. - -In modern physics the need for efficient data processing is pressing: - -- Large simulations for cosmology, fluid dynamics, or materials science are extremely computationally intensive, running for days, weeks, or months at a time. -- [LSST](https://www.lsst.org/about/dm), a cosmological observatory, will receive about 20TB of data _per day_. -- Engineering applications often have to be responsive in real time. -- Many systems are highly restricted - for example satellites which have to be launched into space - and therefore need to get the most work possible out of their hardware. - -Regardless of your particular field, high performance computing is often desirable, allowing us to explore more detailed and advanced models than we could normally. As researchers, it's our job to push those boundaries! - -## When To Start Optimising Code - -When faced with writing code that one knows needs to be efficient, it is common to try to optimise every piece of code they write as one goes along. This can lead to a very slow development process, as well as difficult to understand software! There are some key points to bear in mind when you are developing efficient code: - -- Optimisation often takes significant work: it is much slower to write a highly optimised implementation than a straight-forward one. -- Optimised code is often unintuitive and therefore harder to understand for new users. This also makes maintenance harder, especially if it is not obvious what can and can't be changed without breaking the optimisation benefits! -- Highly optimised code may not be portable, as code may be optimised for specific platforms, architectures, or even individual machines. -- KISS (Keep It Simple, Stupid) is a good principle for first drafts: focus on writing _correct_, understandable code first. -- Measure performance to find your bottlenecks: don't waste time optimising parts of your program that only make up a tiny fraction of your runtime (or memory). Chances are there are only one or two places where a substantial amount of your runtime is spent and which can be effectively optimised. **Profile your program and prioritise high-impact optimisations first.** -- Continue measuring performance to check that you are making an impact on your runtime / memory usage. -- Monitor your optimised algorithms for **accuracy and precision**. Some algorithms may produce more reliable results than others, especially when using floating point arithmetic. A significant consideration with numerical algorithms is not just the time and space usage, but the numerical stability of the algorithm. - - For example some methods use single (or even half) precision floating point numbers instead of doubles in order to get faster results. This sometimes happens in some statistical methods such as MCMC (Markov Chain Monte Carlo) where we want to be able to sample a random distribution and the additional precision doesn't have a strong impact on the overall statistical properties. - - Some methods use arbitrary precision integer arithmetic to handle very large values and do calculations without e.g. cancellation errors, even though this requires more time and memory. This is common in things like financial applications where all the sums really need to add up exactly! - -**Optimisation is usually a trade off between different factors. You should always bear in mind your priorities when creating efficient software.** - -## What Forms Can Optimisation Take? - -Optimisation can take a variety of forms: - -- Time / Space efficient algorithms (reduced computational complexity) -- Exploiting specialised hardware (e.g. vectorised arithmetic units, caches) -- Automated optimisation by compilers -- Concurrency (see the next three weeks!) \ No newline at end of file diff --git a/06tooling/sec00TimingAndTooling.md b/08openmp/sec00TimingAndTooling.md similarity index 100% rename from 06tooling/sec00TimingAndTooling.md rename to 08openmp/sec00TimingAndTooling.md diff --git a/07performance/DataStructures.md b/10parallel_algorithms/DataStructures.md similarity index 98% rename from 07performance/DataStructures.md rename to 10parallel_algorithms/DataStructures.md index d31cd038d..b0d6d863c 100644 --- a/07performance/DataStructures.md +++ b/10parallel_algorithms/DataStructures.md @@ -46,11 +46,11 @@ where $N$ is the number of rows and $M$ is the number of columns. In this implem Let's review how a vector is arranged in memory to understand the layout of our vector of vectors. Remember that a vector stored on the stack makes a heap allocation to store its data, which means that under the hood a vector is using a pointer to keep track of the location of the actual data. Below is an example of a vector of ints (green) stored on the stack (blue), with an allocation (yellow) on the heap (red). -![image](img/BasicVector.png) +![image](images/BasicVector.png) When we have a vector of vectors, each element of the vector's data is itself a vector, which is pointing to a separate location in memory to store its own data. Below is a diagram of a $5 \times 4$ matrix (5 rows, 4 columns); the vector of vectors point to an allocation on the heap containing 5 vectors (one for each row), which themselves each points to a block of memory 4 ints wide. These blocks of memory can in principle be placed anywhere in memory. -![image](img/VecOfVec.png) +![image](images/VecOfVec.png) Each row is clearly contigous but the columns are not. For example, the first column of this matrix would be made up of the first element of each row, which are placed independently throughout memory. @@ -84,7 +84,7 @@ A _linked list_ is a representation of a list that is stored like a graph: each The diagram shows a possible layout linked list nodes in memory. The red grid shows memory locations, blue cells are occupied by a linked list data element and the yellow cells are occupied by a linked list pointer; arrows show the location that each pointer stores. Note the list can be terminated with a null pointer. -![image](img/LinkedListMemory.png) +![image](images/LinkedListMemory.png) Accessing element $i$ of the list requires us to read and follow $i$ pointers, and the amount of work done to find elements increases linearly as the we get further into the list. The advantage of a linked list however is that we can add or remove elements more straightforwardly by simply modifying the relevant pointers. This is much simpler than removing or inserting elements in the middle of a random access array, which requires copying memory to keep all the elements correctly in order. @@ -107,7 +107,7 @@ A BST is therefore always _sorted_ w.r.t. keys. It is often used to implement _a The diagram below shows a BST; the first piece of data (in the blue cell) is the _key_, followed by value, the left child pointer, and the right child pointer. A null pointer can be used to represent a lack of left or right child. Note that the tree is sorted by the _key_ and is not sorted on _values_. -![image](img/BinarySearchTree.png) +![image](images/BinarySearchTree.png) The complexity of many operations on BSTs is determined by the _height_ of the tree. The height of a tree (or subtree) is the maximum length of path that we can take from the top node until we reach a node with no children and can go no further. The diagram above shows a tree of height 3; the subtree starting at the node with $k=4$ has height 2. @@ -123,7 +123,7 @@ A _hash table_ is an alternative implementation for an associative array. It con The diagram below gives an example of a hash table for the same data as the BST above. In the ideal case all the keys would map to different indices in the table, but for illustration purposes we have shown a number of collisions. From each hash function on the left we get an index in the random access array, and then we can follow a pointer to all the data stored at that index. -![image](img/HashTable.png) +![image](images/HashTable.png) How quick it is to look up an element in this list will depend on the kind of structure used (for example, all of the above structures could be used!) but the key to a hash table's performance is that **collisions should be rare** so that the size of these lists remains small. If the number of colliding keys is bounded by a constant then the look-up/insertion in the list will constant time, and since the hash function and checking the random access array are also constant time, hash tables have $O(1)$ operations for insertion, look-up, and removal. Just because the complexity is $O(1)$ (under appropriate circumstances) doesn't necessarily mean that hash tables are faster than other methods though: there are overheads to think about as well, especially from the hash function! Often a BST will be faster for moderately sized data. Hash tables can also require allocating more memory than you need. diff --git a/07performance/img/BasicVector.png b/10parallel_algorithms/images/BasicVector.png similarity index 100% rename from 07performance/img/BasicVector.png rename to 10parallel_algorithms/images/BasicVector.png diff --git a/07performance/img/BinarySearchTree.png b/10parallel_algorithms/images/BinarySearchTree.png similarity index 100% rename from 07performance/img/BinarySearchTree.png rename to 10parallel_algorithms/images/BinarySearchTree.png diff --git a/07performance/img/HashTable.png b/10parallel_algorithms/images/HashTable.png similarity index 100% rename from 07performance/img/HashTable.png rename to 10parallel_algorithms/images/HashTable.png diff --git a/07performance/img/LinkedListMemory.png b/10parallel_algorithms/images/LinkedListMemory.png similarity index 100% rename from 07performance/img/LinkedListMemory.png rename to 10parallel_algorithms/images/LinkedListMemory.png diff --git a/07performance/img/VecOfVec.png b/10parallel_algorithms/images/VecOfVec.png similarity index 100% rename from 07performance/img/VecOfVec.png rename to 10parallel_algorithms/images/VecOfVec.png