Index Ai Academy
Phase 7 · Software engineering · How computers run code

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.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. C: types, pointers and loops
  2. Translate a C function
  3. C++: the same, with classes and templates
  4. CUDA: one thread per element
  5. Rust: types after names, and ownership
  6. Rust's Option and Result
  7. Find the bug: a translated loop
  8. A strategy for reading unfamiliar code
Quick check
A guess first. In C, -7 / 2 with two integers gives -3: it rounds toward zero. Which Python expression gives the same answer?
print(int(-7 / 2), -7 // 2)
  1. -3 -4
  2. -4 -4
  3. -3.5 -4
  4. -3 -3
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.

Drill

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]))
In the lesson, your code is checked as you write it.
In your own words
What's the trap when translating C's integer division into Python?

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.

Quick check
In the C++ function above, what would change if the loop read for (auto v : values), without the &?
  1. The code would no longer compile at all
  2. Only the first element would get clamped
  3. The vector stays unchanged; only the count goes up
  4. Nothing changes: auto always means a reference
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.

Quick check
A kernel processes n = 1000 elements with 256 threads per block. What does this print?
n, threads = 1000, 256
blocks = (n + threads - 1) // threads
print(blocks, blocks * threads - n)
  1. 1000 256
  2. 3 232
  3. 4 24
  4. 4 0
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.

Try it now

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.

Quick check
In Rust, let names = vec!["tent"]; let kept = names; and then a line that prints names. What happens?
  1. It crashes when the line runs
  2. It doesn't compile: names moved to kept
  3. It prints an empty vector
  4. It prints ["tent"], as Python would
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()).

In your own words
Why does every CUDA kernel check i < n, and what happens to a Rust value after it's moved?

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.

Quick check
A reminder from Python typing. A Rust function returns Option<User>. Which Python annotation says the same thing?
  1. User
  2. User | None
  3. list[User]
  4. Optional[str]
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.

Drill

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]))
In the lesson, your code is checked as you write it.

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.

Quick check
A PyTorch operator is slow on your data, and you open its C++ source for the first time. What do you read first?
  1. Its signature, then the loop or kernel doing the work
  2. The tests, to find the slowest one
  3. The build system, to see how it's compiled
  4. The file from the top, starting with every #include
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.

In your own words
What's the reading strategy for unfamiliar native code?

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

Create a free account

Try a quiz question

In a C function, what does float *x usually mean for an argument?

  1. x points at the first of some floats, with the length passed separately
  2. x is a single float multiplied by something
  3. x is a Python-style list of floats that knows its length
  4. x is optional and may be skipped

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson