Reading C, C++, CUDA and Rust
Pointers and loops in C, references and templates in C++, one-thread-per-element CUDA kernels, and Rust's Option, Result and ownership.
Create a free account Try part of it here
Why it matters
Under the Python you write, the heavy lifting is done in other languages. PyTorch's operators are C++ and CUDA; llama.cpp runs models in C and C++; Hugging Face's tokenizers, uv, Ruff and Polars are Rust. You won't write much of it, but you'll read it: to find out why an operation is slow, what a library really does with your input, or whether a pull request to a dependency is safe. Reading is a much smaller skill than writing, and this lesson teaches just that part.
What you'll be able to do
- Read C and C++ function signatures, loops, pointers and arrays, and say what the Python equivalent would be
- Follow a CUDA kernel: which element each thread handles, and why the bounds check is there
- Read Rust's ownership, borrowing, Option and Result well enough to follow a function
- Use a reading strategy for unfamiliar code in any of these languages
What it covers
- C: types, pointers and loops
- Translate a C function
- C++: the same, with classes and templates
- CUDA: one thread per element
- Rust: types after names, and ownership
- Rust's Option and Result
- Find the bug: a translated loop
- A strategy for reading unfamiliar code
print(int(-7 / 2), -7 // 2)
Show the answer
-3 -4
Python's // rounds down, toward minus infinity, so -7 // 2 is -4. int(-7 / 2) truncates toward zero like C. Small differences like this are why translating carefully matters.
C: types, pointers and loops
C is the language under almost everything. Three things look different from Python. Every variable has a declared type (int n, float x, double total). Arrays are passed as pointers, the address of their first element, together with a separate length. And loops spell out the counter.
// Dot product of two float arrays of length n
float dot(const float *a, const float *b, int n) {
float total = 0.0f;
for (int i = 0; i < n; i++) {
total += a[i] * b[i];
}
return total;
}
Read it as Python: def dot(a, b, n): total = 0.0; for i in range(n): total += a[i] * b[i]; return total. The return type comes first, float *a means "a points at floats", and const promises the function won't change them. C doesn't check that i stays inside the array: reading a[n] doesn't raise, it reads whatever memory comes next. That's why C code is full of explicit lengths, and why bugs there become security problems.
Memory works the same hands-on way. A block you ask for with malloc stays yours until you call free; forget, and the program leaks; free it twice or use it afterwards, and it crashes or worse. C++ and Rust exist largely to make that bookkeeping automatic.
Translate a C function
Translate this C function into Python as sum_squares(xs), taking a list (Python lists know their length, so there's no n).
long sum_squares(const int *xs, int n) {
long total = 0;
for (int i = 0; i < n; i++) {
if (xs[i] > 0) total += (long)xs[i] * xs[i];
}
return total;
}def sum_squares(xs): # only the positive numbers count return 0 print(sum_squares([3, -2, 4]))
Write a sentence or two, then compare it with a good answer in the lesson.
C++: the same, with classes and templates
C++ adds what large programs need, and PyTorch's core is written in it. You'll meet std::vector<float>, a growable array like a list; namespace::name for modules, as in torch::Tensor or std::max; classes with methods; and templates, template <typename T>, which write one function for many types, a little like a generic type hint that the compiler turns into separate copies.
template <typename T>
int clamp_all(std::vector<T>& values, T low, T high) {
int changed = 0;
for (auto& v : values) {
if (v < low) { v = low; changed++; }
else if (v > high) { v = high; changed++; }
}
return changed;
}
for (auto& v : values) is for v in values, and the & makes v a reference: changing v changes the element in the vector itself, the way changing values[i] would. Without the &, v would be a copy and the function would change nothing. auto lets the compiler work out a type, like leaving off a type hint.
Show the answer
The vector stays unchanged; only the count goes up
Without &, each v is a copy. The copies get clamped and thrown away, so values comes back as it was, while changed still counts what it would have done.
CUDA: one thread per element
A GPU runs thousands of threads at once, all running the same function, a kernel, on different elements. Threads are grouped into blocks, and each thread works out which element is its own from three built-in numbers:
__global__ void scale(const float *x, float *out, float a, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) {
out[i] = a * x[i];
}
}
// on the CPU: enough blocks of 256 threads to cover n elements
int blocks = (n + 255) / 256;
scale<<<blocks, 256>>>(x, out, 2.0f, n);
__global__ marks a kernel; <<<blocks, threads>>> launches it. Block 2, thread 5, with 256 threads per block, handles element 2 * 256 + 5. The number of blocks is rounded up so every element gets a thread, which means the last block usually has spare threads past the end. if (i < n) stops those from writing past the array, a write that C wouldn't catch for you.
The other thing to look for is copying. A kernel can only use data that's already in the GPU's memory, and moving data between the computer's memory and the GPU (cudaMemcpy in CUDA) is slow next to the arithmetic itself. That's why PyTorch keeps tensors on the GPU from one operation to the next, and why a stray .item() or .cpu() inside a training loop, which forces a copy back every step, can make the whole loop crawl.
n, threads = 1000, 256 blocks = (n + threads - 1) // threads print(blocks, blocks * threads - n)
Show the answer
4 24
Rounding up gives 4 blocks, 1024 threads in all, and 24 of them have no element. Those 24 are why every kernel checks i < n.
Spot the language from one or two telltale marks: that's often all you need to start reading.
Rust: types after names, and ownership
Rust looks like a mix of C and Python, with types after names (count: usize) and let (or let mut for a value that changes). Its unusual idea is ownership: each value has one owner, and passing it to a function moves it unless you lend it with a reference, &value to read or &mut value to change. That's how Rust frees memory safely without a garbage collector, and why you'll see so many &s.
Show the answer
It doesn't compile: names moved to kept
Assigning moves the vector to kept, and Rust refuses to compile any later use of names. To keep both, lend it (&names) or clone it (names.clone()).
Write a sentence or two, then compare it with a good answer in the lesson.
Rust's Option and Result
Errors in Rust are values, returned like any other result:
fn parse_port(text: &str) -> Result<u16, String> {
let n: u32 = text.trim().parse().map_err(|_| format!("not a number: {text}"))?;
if n == 0 || n > 65535 {
return Err(format!("out of range: {n}"));
}
Ok(n as u16)
}
Rust has no exceptions and no None that sneaks in. A value that may be missing is an Option (Some(x) or None), and a call that may fail returns a Result (Ok(value) or Err(error)). The ? at the end of a line means "if this is an error, return it now", much like letting a Python exception propagate. .unwrap() means "I'm sure it's fine, crash if not": worth a second look in review.
Show the answer
User | None
Option<User> is a User or nothing, which Python spells User | None (or Optional[User]). Rust makes the caller handle the None case before using the value.
Find the bug: a translated loop
Someone translated this C loop into Python, and the Python version raises IndexError. In C it didn't crash; it quietly read one value past the end of the array. Fix the Python.
for (int i = 0; i <= n; i++) total += prices[i];
def total_price(prices): n = len(prices) total = 0 for i in range(n + 1): total += prices[i] return total print(total_price([189, 12, 79]))
A strategy for reading unfamiliar code
You don't read a C++ file from the top. Find the function you care about, say the operator your model calls, and read its signature first: what goes in, what comes out, what may fail. Then find the loop or the kernel launch where the real work happens and translate just that part in your head to Python. Skip includes, macros and error-handling boilerplate on the first pass.
Keep a short list of questions as you go. Where's the length checked? Who frees this memory, or who owns this value in Rust? What happens on the error path? Is the work parallel, and if so, what does each thread own? Those questions find most real bugs and most performance surprises.
And use the tools: GitHub's code search, "go to definition" in an editor, and a model asked to explain one function at a time, checked against the code itself rather than trusted.
Show the answer
Its signature, then the loop or kernel doing the work
The signature tells you the inputs and outputs, and the inner loop or kernel is where time goes. Everything else can wait for a second pass.
Write a sentence or two, then compare it with a good answer in the lesson.
Practice what you just read
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
In a C function, what does float *x usually mean for an argument?