TL;DR: Python developers live at the top of a seven-layer stack and rarely think about what’s underneath. Then one day pip install fails with a C++ compiler error and suddenly you’re in a world of shared libraries, linkers, and CMake. This post maps that world. The concepts sound scary but they’re just different nouns for the same verbs you already know: compile, link, package, install, import.
Companion to How Gazelle Resolves Python Imports, which covers the Python layer. This post covers everything below that layer — the native dependency world that Python developers eventually fall into.
The seven layers
Here’s the full stack. Every import torch touches all of these:
Layer 7: import torch ← you live here
Layer 6: pip install torch ← package manager
Layer 5: torch-2.1.0-cp312-linux.whl ← package format
Layer 4: setuptools / maturin ← packaging tool
Layer 3: cmake / meson / bazel ← build system
Layer 2: gcc / clang / nvcc ← compiler
Layer 1: .c / .cpp / .cu source files ← human-written code
Layer 0: CPU instructions / GPU kernels ← what actually runs
Python developers spend their entire career at layer 7, occasionally dipping to layer 6 when something needs installing. You never think about layers 0–4. Why would you? import torch works. That’s the whole point of abstraction.
The dread comes from the day it stops working. pip install fails. The error says something about gcc. Or CMakeLists.txt. Or libcudart.so.12: cannot open shared object file. And suddenly you’re free-falling from layer 7 to layer 2 with no parachute and no vocabulary for what you’re looking at.
This post is the vocabulary.
The six things that sound scary
Let’s start with the concepts you’ll encounter first, roughly in order of “how likely this is to show up in your error messages.”
GCC and Clang: compilers
A compiler reads source code written by humans (.c, .cpp) and produces machine code that CPUs can execute. That’s the whole job. Source in, binary out.
GCC — GNU Compiler Collection. The Toyota Camry of compilers. Ships with every Linux distro, has been around since 1987, does the job without drama. If you’re on a Linux server and something compiled successfully, GCC probably did it.
Clang — LLVM-based compiler. The Tesla. Newer architecture, better error messages, faster compile times in many cases. Apple ships it as the default on macOS (when you type gcc on a Mac, you’re actually running Clang wearing a GCC costume — check with gcc --version).
They do the same job. They accept the same source files. They produce equivalent output. The differences matter to compiler engineers and build system authors. They do not matter to you. If an error says “gcc not found,” install gcc. If it says “clang not found,” install clang. Both turn .c files into machine code.
foo.c → gcc → foo.o (object file — compiled but not yet runnable)
bar.c → gcc → bar.o
foo.o + bar.o → linker → libfoo.so (shared library — loadable at runtime)
The step from .o to .so is linking — we’ll get to that.
CMake: a build system for C/C++
CMake is to C++ what pyproject.toml + uv is to Python: it describes what to build and how, then invokes the compiler to do it.
Except CMake is actually a meta-build system. It doesn’t compile anything itself. It reads a CMakeLists.txt file, figures out what needs compiling and in what order, then generates instructions for another tool (Make, Ninja, or Visual Studio) to actually run the compiler. Two levels of indirection before any code gets compiled. Welcome to C++.
CMakeLists.txt → cmake → Makefile → make → gcc → .so files
CMakeLists.txt is like BUILD.bazel for C++ projects. It says: “compile these source files, link against these libraries, produce this output.” The syntax is its own language, looks nothing like Python, and has roughly the ergonomics of a 2003 enterprise XML config. But it’s everywhere.
If you see a project with CMakeLists.txt in the root, you know: this has C/C++ code, it builds with CMake, and pip install will probably need a compiler available.
setuptools: Python’s packaging tool
setuptools takes your Python code and — optionally — your compiled C extensions, and bundles them into a distributable package.
For pure Python packages, setuptools is simple: zip up the .py files, add metadata, done. The resulting package works everywhere Python works.
For packages with C extensions (numpy, cryptography, torch), setuptools has a much harder job: it invokes the C compiler, compiles the extension code, bundles the resulting .so files alongside the .py files, and records which platform/architecture this was built for. The output is a platform wheel — more on that next.
setuptools is the reason python setup.py build_ext exists. It’s also the reason pip install cryptography sometimes prints 200 lines of compiler output. setuptools is calling gcc for you. When it works, you never know. When it fails, you get a wall of C compiler errors and wonder what you did to deserve this.
Modern alternatives: maturin (for Rust extensions via PyO3), scikit-build (CMake integration), meson-python (Meson integration). They all do the same fundamental thing — compile native code, bundle it with Python — with different build system backends.
Wheels and sdists: the two package formats
When pip installs a package, it downloads one of two things:
A wheel (.whl) — a pre-compiled package. It’s literally a zip file with a funny extension and a naming convention that encodes what platform it was built for:
torch-2.5.1-cp312-cp312-manylinux_2_17_x86_64.whl
│ │ │ │ │ │
│ │ │ │ │ └── CPU architecture
│ │ │ │ └── Linux with glibc >= 2.17
│ │ │ └── Python ABI tag
│ │ └── CPython 3.12
│ └── version
└── package name
A pure Python wheel says py3-none-any — works on any Python 3, any OS, any architecture. A platform wheel like the one above only works on that specific combination. The .so files inside were compiled for that exact platform. Move it to a Mac? Garbage. Move it to ARM Linux? Garbage.
When pip finds a matching wheel, installation is just unzipping. Fast. No compiler needed. This is why pip install flask takes 2 seconds and never fails.
An sdist (.tar.gz) — source code. “Here’s the recipe, you cook it.” pip downloads the source, extracts it, runs the build system (setuptools, CMake, whatever), which calls the compiler, which produces the .so files on your machine.
Every error message of the form “Failed building wheel for X” means: pip couldn’t find a pre-compiled wheel, fell back to the sdist, tried to compile it on your machine, and something went wrong. Usually a missing compiler, missing headers, or missing system library.
The lifecycle of a package like torch:
C++/CUDA source → cmake → gcc/clang/nvcc → .so files
→ setuptools bundles .py + .so → creates .whl
→ uploaded to PyPI → pip install just unzips it
The PyTorch team compiles wheels for every supported platform so you don’t have to. When you pip install torch, you’re downloading a 2GB zip file of pre-compiled CUDA kernels and C++ extensions. The build took hours on their CI. Your install takes 30 seconds on your network. That’s the wheel economy.
Shared libraries, native libraries, C extensions, modules — untangled
These four terms overlap in confusing ways. The same .so file gets called different things depending on who’s talking about it and what they care about.
Module — anything Python can import. A .py file is a module. A package (directory with __init__.py) is a module. A compiled .so file with Python bindings is a module. The broadest term.
Shared library (.so on Linux, .dylib on macOS, .dll on Windows) — an OS-level concept. Compiled machine code that can be loaded into memory at runtime by any program that needs it. The “shared” means multiple programs can share the same loaded copy in memory.
Native library — a contrast term. “Not written in Python.” Could be C, C++, Rust, Fortran, CUDA — anything that compiles to machine code. When someone says “this package has native dependencies,” they mean “somewhere in the dependency chain, there’s compiled code that isn’t Python.”
C extension — a shared library with Python bindings. It uses Python’s C API so that Python code can call functions in it. When you import _sqlite3, Python loads a .so file that speaks both C and Python’s internal protocol.
The Venn diagram:
- All C extensions are shared libraries, but not all shared libraries are C extensions.
libcudart.sois a shared library but NOT a C extension — Python doesn’t import it directly. torch’s_C.so(a C extension) loads it as a dependency.utils.pyis a module but not a shared library.torch/_C.cpython-312-x86_64-linux-gnu.sois all four: a module, a shared library, a native library, and a C extension.
┌─────────────────────────────────────┐
│ Shared libraries │
│ (libcudart.so, libstdc++.so, ...) │
│ │
│ ┌──────────────────────────────┐ │
│ │ C extensions │ │
│ │ (torch/_C.so, numpy/...) │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Python modules │
│ (.py files, packages, AND the C extensions above) │
└──────────────────────────────────────────────────────────┘
Ways to create C extensions
If you ever need to call C/C++ code from Python (or understand how a package does it):
| Method | Language | How it works | Used by |
|---|---|---|---|
| Raw C API | C | #include <Python.h>, implement PyInit_module |
CPython stdlib |
| Cython | Python-ish → C | Write .pyx files, Cython generates C, compiler builds .so |
scipy, pandas (some parts) |
| pybind11 | C++ | C++ header-only library, generates bindings | PyTorch, many ML libs |
| cffi | C | Define C functions in Python strings, call them | cryptography |
| ctypes | C | Load any .so at runtime, call functions by name |
stdlib, quick hacks |
| PyO3 + maturin | Rust | Write Rust, compile to Python extension | pydantic-core, ruff |
The trend is toward Rust (PyO3) and away from raw C API. But the C/C++ ecosystem is still massive, and torch is unlikely to be rewritten in Rust anytime soon.
Static vs. dynamic linking
When compiled code needs to use functions from another compiled library, there are two ways to wire them together:
Static linking — copy the library’s code directly into your binary at build time. The result is self-contained. Bigger file, but no runtime dependencies.
foo.o + libbar.a → linker → foo (contains bar's code baked in)
Dynamic linking — record “I need libbar” but don’t include it. At runtime, the OS loader finds libbar.so, loads it into memory, and connects the references.
foo.o + libbar.so → linker → foo (contains a reference to libbar.so)
↑ runtime: OS loads libbar.so
Static linking makes binaries that “just work” anywhere — no “missing .so” errors. But every binary has its own copy of everything, which wastes disk and memory.
Dynamic linking saves space and allows updating libraries without recompiling everything. But you need the right .so files present at runtime, with the right versions, in a path the OS knows about. This is why LD_LIBRARY_PATH exists and why so many things break when shared libraries go missing.
File extensions:
.a— static library (archive of.ofiles).so— shared/dynamic library (Linux).dylib— shared library (macOS).dll— dynamic library (Windows)
Bazel’s preference: static linking whenever possible, because it supports hermeticity — the output doesn’t depend on what .so files happen to be on the machine.
Header files (.h)
Header files are declarations. They tell the compiler “these functions exist, they take these arguments, they return these types.” They don’t contain the actual implementation — that’s in the .c/.cpp files.
Think of them like TypeScript .d.ts files. Or like Python type stubs (.pyi). They describe the shape of things without the body.
// math_utils.h — declaration
int add(int a, int b);
// math_utils.c — implementation
int add(int a, int b) {
return a + b;
}
When you compile code that calls add(), the compiler reads the header to know the function exists and what types it uses. The linker later connects the call to the actual compiled implementation.
Why this matters to you: The error “Python.h not found” means you’re trying to compile a C extension but don’t have Python’s development headers installed. The C code includes #include <Python.h> — and that file lives in a -dev package:
# Debian/Ubuntu
apt install python3.12-dev # gives you Python.h
apt install libffi-dev # gives you ffi.h
apt install libssl-dev # gives you openssl/ssl.h
The actual Python runtime (what runs your .py files) doesn’t need these headers. Only compilation does. That’s why they’re in a separate -dev package — the compiler needs them, the runtime doesn’t.
Common “missing header” errors and what to install:
| Error | Missing header | Install |
|---|---|---|
Python.h: No such file |
Python dev headers | python3.12-dev |
ffi.h: No such file |
libffi dev headers | libffi-dev |
openssl/ssl.h: No such file |
OpenSSL dev headers | libssl-dev |
cuda_runtime.h: No such file |
CUDA toolkit headers | CUDA toolkit |
The build system landscape
If you’ve ever been in a conversation where someone asks “Is Bazel like CMake?” and you didn’t know how to answer — this section is for you.
Build systems exist on a spectrum from “I build one language” to “I orchestrate everything.” The confusion comes from using the same word — “build system” — for tools at very different points on that spectrum.
Language-specific build tools
These are tightly integrated with one language and its ecosystem. They know about that language’s compilation model, its package format, its test conventions.
| Tool | Language | What it does |
|---|---|---|
| Cargo | Rust | Compiles, tests, benchmarks, publishes crates. The gold standard. |
| go build | Go | Compiles Go packages. Downloads dependencies. That’s it. |
| dotnet build | C# | Compiles .NET projects, manages NuGet packages. |
| tsc | TypeScript | Type-checks and compiles .ts → .js. |
| javac | Java | Compiles .java → .class bytecode. |
These are not confused with Bazel. Nobody asks “is Cargo like Bazel?” because the answer is obviously “Cargo builds Rust, Bazel builds everything.”
Language-ecosystem package managers
These manage dependencies and often orchestrate builds for their language’s package ecosystem:
| Tool | Language | Scope |
|---|---|---|
| npm / yarn / pnpm | JavaScript | Install packages, run scripts, manage lockfiles |
| pip / uv | Python | Install packages, resolve dependencies |
| Maven | Java/enterprise | Build, dependency management, project lifecycle |
| Gradle | Java/Android/Kotlin | Build automation, dependency management |
| sbt | Scala | Build, test, publish |
These get confused with Bazel because they do some of the same things — dependency resolution, build orchestration. But they’re scoped to one ecosystem.
Native/C++ build tools
This is the layer Python developers rarely see:
Make (1976) — the original. Reads a Makefile, runs commands based on file timestamps. No dependency resolution, no package management, just “if this file changed, run this command.” Ancient, universal, and still everywhere.
CMake — a meta-build system. Reads CMakeLists.txt, generates Makefiles (or Ninja build files), which then call gcc/clang. The extra layer of indirection exists because CMake supports multiple output formats (Make, Ninja, Visual Studio, Xcode). It’s the most common C++ build system by a wide margin.
Meson — a simpler, faster CMake alternative. Generates Ninja files only. Growing in popularity, especially in the Linux/GNOME world. meson-python bridges it to Python packaging.
Ninja — a build executor, not a build generator. CMake and Meson generate Ninja files; Ninja runs them very fast. It’s the “fast make replacement” that you never interact with directly.
Autotools — the ancient ./configure && make && make install dance. Still exists in older projects. Generates Makefiles by interrogating your system for available compilers, libraries, and features. If you’ve ever compiled something from source on Linux, you’ve probably used Autotools without knowing it.
Multi-language / monorepo build systems
These are the generals — they don’t compile code themselves, they orchestrate other tools that do:
| Tool | Origin | Scope |
|---|---|---|
| Bazel | Multi-language, hermetic, massive scale | |
| Buck2 | Meta | Similar to Bazel, different implementation |
| Pants | Twitter → open source | Python/JVM focused, Bazel-inspired |
| Nx / Turborepo | JS ecosystem | JavaScript/TypeScript monorepo orchestration |
The cheat sheet
Here’s how to answer the “is Bazel like X?” question:
“Is Bazel like CMake?” CMake compiles C++. Bazel orchestrates entire builds across languages. CMake is the carpenter. Bazel is the general contractor who hires carpenters, electricians, and plumbers.
“Is Bazel like Gradle?” Closest comparison. Gradle manages Java/Android builds with dependency resolution, caching, and incremental compilation. Bazel does the same but language-agnostic and designed for codebases 10-100x larger.
“Is Bazel like Make?” Bazel is what Make would be if someone redesigned it in 2015 with Google’s scale problems in mind. Same basic concept — “if inputs changed, rebuild outputs” — but with content-based caching, sandboxing, remote execution, and actual dependency graphs instead of file timestamps.
“Is Bazel like Docker?” Different layer. Docker gives you a reproducible environment (same OS, same libraries, same tools). Bazel gives you reproducible builds (same inputs always produce the same outputs). You can — and often do — use both: Docker pins the environment, Bazel pins the build.
“Is Bazel like npm?” npm resolves and installs JavaScript packages. Bazel orchestrates compilation, testing, and packaging across languages. npm is one of the things Bazel might orchestrate.
The reason this is confusing is that “build system” is overloaded. CMake is a build system. Make is a build system. Bazel is a build system. pip is sometimes called a build system. They all have the word “build” in their job description but they’re doing fundamentally different jobs at different layers of the stack.
Linker errors — the ones you’ll actually hit
When things break in the native dependency world, they break in a small number of characteristic ways. Learn to recognize the pattern and you can usually diagnose the problem in under a minute.
“undefined symbol”
ImportError: /path/to/torch/_C.so: undefined symbol: _ZN3c106detail...
Translation: the .so file was compiled expecting a function to exist, but at runtime, that function isn’t there. Usually a version mismatch — the .so was compiled against one version of a library but is being loaded alongside a different version.
Common cause: pip installed a wheel compiled against CUDA 12.1 but your system has CUDA 11.8. The function signatures changed between versions.
Fix: make sure the versions match. nvidia-smi shows your driver’s max CUDA version. The wheel’s filename tells you what it was compiled for.
“cannot open shared object file”
OSError: libcudart.so.12: cannot open shared object file: No such file or directory
Translation: the OS dynamic linker can’t find a .so file that’s needed. The compiled code says “I need libcudart.so.12” and the linker looked in all its search paths and came up empty.
Common cause: the library exists but isn’t on the search path. Or it’s not installed at all.
Fix: either install the missing library, or tell the linker where it is:
# Option 1: add to search path
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:$LD_LIBRARY_PATH
# Option 2: check if it exists somewhere
find / -name "libcudart.so*" 2>/dev/null
# Option 3: use ldd to see all missing dependencies
ldd /path/to/the_broken.so
“version GLIBCXX_3.4.30 not found”
ImportError: /lib/x86_64-linux-gnu/libstdc++.so.6: version `GLIBCXX_3.4.30' not found
Translation: the .so was compiled with a newer version of GCC than what’s installed on this machine. GLIBCXX is the C++ standard library’s versioned ABI symbol. The number maps to a GCC version.
Common cause: a wheel was compiled on a build machine with GCC 12, but you’re running on a machine with GCC 9. The C++ standard library is too old.
Fix: upgrade your system’s libstdc++, or use a wheel compiled for an older glibc (look for manylinux2014 instead of manylinux_2_28 in the wheel name).
This error is the reason manylinux exists.
manylinux, glibc, and ABI — the compatibility layer
glibc
glibc (GNU C Library) is the most fundamental library on a Linux system. It provides the basic system calls — reading files, allocating memory, creating threads, everything. Every compiled program on Linux depends on it. It’s the floor the house is built on.
glibc versions are backward-compatible: code compiled against glibc 2.17 works on glibc 2.31. But not the reverse — code compiled against 2.31 might use functions that 2.17 doesn’t have.
Check your version:
ldd --version
# ldd (Ubuntu GLIBC 2.35) 2.35
manylinux
manylinux is a set of standards that says: “if you compile your wheel against this specific old version of glibc, it’ll work on any Linux system that has glibc >= that version.”
| Tag | glibc version | Compatible with |
|---|---|---|
manylinux2014 |
2.17 | Almost everything |
manylinux_2_28 |
2.28 | Most recent distros |
manylinux_2_31 |
2.31 | Ubuntu 20.04+ |
manylinux_2_35 |
2.35 | Ubuntu 22.04+ |
The older the tag, the more compatible the wheel — but the less modern C library features the code can use. Package maintainers target the oldest glibc they can get away with.
When pip selects a wheel, it checks your system’s glibc version and downloads the most compatible one. If your glibc is 2.35, pip will happily install a manylinux2014 wheel (compiled for 2.17, your 2.35 is newer). But it won’t install a manylinux_2_35 wheel on a system with glibc 2.28.
ABI
ABI — Application Binary Interface — is the binary-level protocol between compiled code. If a function takes an int and returns a struct, the ABI defines exactly how those values are passed in CPU registers and memory.
You don’t need to understand ABI at a deep level. What you need to know: ABI compatibility is why you can’t mix and match compiled libraries from different compilers or different compiler versions. If library A was compiled with one ABI and library B expects another, you get “undefined symbol” or segfaults.
The cp312 in a wheel filename is the CPython ABI tag — it means “compiled against CPython 3.12’s C API.” You can’t load a cp311 extension in Python 3.12 because the ABI changed.
Name mangling
C++ encodes function names with their types to support function overloading. A function int add(int, int) becomes something like _Z3addii in the compiled .so. This is called name mangling.
When you see an “undefined symbol” error with a name like _ZN3c106detail19torchCheckMsgImplEv, that’s a mangled C++ symbol. You can decode it:
c++filt _ZN3c106detail19torchCheckMsgImplEv
# c10::detail::torchCheckMsgImpl()
Now you know which function is missing, and you can figure out which library should have provided it.
CUDA: everything you need to know
CUDA is NVIDIA’s platform for running code on GPUs. If you do ML, you can’t avoid it. Here are the concepts that matter for debugging, organized by “how likely you are to hit this.”
CUDA Toolkit vs. CUDA Driver
There are two separate CUDA versions on any system, and they have a compatibility relationship that has confused every person who has ever used a GPU.
CUDA Driver — installed as part of the NVIDIA GPU driver. Lives on the host machine. You can see it with:
nvidia-smi
# CUDA Version: 12.4 ← this is the DRIVER's max supported CUDA version
CUDA Toolkit — a set of libraries, compilers, and headers for developing CUDA code. Lives in /usr/local/cuda/ or wherever it was installed. You see it with:
nvcc --version
# Cuda compilation tools, release 12.1 ← this is the TOOLKIT version
The rule: toolkit version must be ≤ driver’s maximum supported version. Driver 12.4 can run code compiled with toolkit 12.1, 12.2, 12.3, or 12.4. But NOT 12.5 — the driver doesn’t know about features that were added after it.
This is forward compatible, not backward compatible. Newer drivers support older toolkits. Older drivers don’t support newer toolkits.
Driver says "CUDA 12.4" → can run toolkit 12.0, 12.1, 12.2, 12.3, 12.4
→ CANNOT run toolkit 12.5, 13.0, etc.
The confusion: nvidia-smi shows the driver’s max supported version, not the installed toolkit version. Many people see “CUDA 12.4” in nvidia-smi and assume that’s what they have. It’s what they can have. What they actually have might be toolkit 11.8.
nvcc
NVIDIA’s compiler for .cu files — CUDA source code. Same role as gcc/clang but for GPU code. Takes CUDA C++ source, produces GPU machine code (PTX or SASS).
kernel.cu → nvcc → kernel.ptx → GPU binary
When PyTorch is compiled from source (or when torch’s sdist is built), nvcc compiles the CUDA kernels. When you install a pre-built wheel, nvcc was already run on the build server.
The CUDA library zoo
NVIDIA provides a set of pre-compiled libraries for common operations. Each is a separate .so:
| Library | What it does | Why it matters |
|---|---|---|
| cuDNN | Neural network primitives (convolutions, attention, pooling) | PyTorch uses it for almost every operation |
| NCCL | Multi-GPU communication (all-reduce, broadcast) | Distributed training |
| cuBLAS | Matrix multiplication and linear algebra | The math behind every linear layer |
| cuFFT | Fast Fourier transforms on GPU | Signal processing, some attention variants |
| cuSPARSE | Sparse matrix operations | Sparse models, some optimizers |
| cuRAND | Random number generation on GPU | Dropout, initialization |
| libcudart | CUDA runtime | Everything — loading kernels, managing GPU memory |
These are NOT Python packages. They’re shared libraries (.so files) that live in the CUDA toolkit or are installed separately. Python packages like torch load them at runtime via the dynamic linker:
import torch
→ loads torch/_C.cpython-312-x86_64-linux-gnu.so (C extension)
→ links to libcudart.so.12 (CUDA runtime)
→ links to libcudnn.so.9 (cuDNN)
→ links to libnccl.so.2 (NCCL)
→ links to libcublas.so.12 (cuBLAS)
Every one of these .so files has a version. Every version has a compatibility matrix with the CUDA toolkit, the driver, and each other. Getting all of them to agree is why Docker images for ML training are 40GB and why NVIDIA publishes compatibility tables that look like they were generated by a hostile AI.
The version compatibility matrix
Here’s why native GPU deps are genuinely hard:
Python version
× OS (Linux distro + kernel version)
× CPU architecture (x86_64, aarch64)
× glibc version
× GCC/Clang version
× CUDA toolkit version
× CUDA driver version
× cuDNN version
× NCCL version
= whether import torch works
That’s 9 variables. Change any one and things might break.
This is why the standard answer in ML is “use Docker.” A Docker image pins every single one of these variables. You’re not asking “will these versions work together?” — you’re saying “I tested these exact versions and they work, here’s the frozen snapshot.” The 40GB image size is the price of sanity.
Bazel tries to solve this differently — by making every dependency explicit and hermetic. But as I wrote about in Three Ways to Smuggle torch into Bazel, some dependencies are too heavy and too hardware-specific for Bazel’s model. The pragmatic answer is: Docker pins the native stack, Bazel manages the Python stack. Draw the boundary honestly.
The full pipeline: how torch gets from source to import
Let’s trace the complete path from “someone at Meta writes C++ code” to “you type import torch”:
1. Source code (Meta/PyTorch team)
pytorch/
├── torch/csrc/ ← C++ source
├── aten/src/ATen/native/ ← native ops (C++/CUDA)
├── torch/cuda/ ← CUDA Python wrappers
├── setup.py ← build configuration
└── CMakeLists.txt ← C++ build configuration
2. Build system (CMake orchestrates compilation)
CMakeLists.txt → cmake → generates Makefile/Ninja files
3. Compilation (multiple compilers, multiple targets)
*.cpp → gcc/clang → CPU code (.o files)
*.cu → nvcc → GPU code (.o files)
All .o files → linker → libtorch.so, libc10.so, torch/_C.so, ...
4. Packaging (setuptools bundles everything)
.py files + .so files + metadata
→ setuptools.build_meta
→ torch-2.5.1-cp312-cp312-manylinux_2_17_x86_64.whl
5. Distribution (uploaded to PyPI and pytorch.org)
.whl files → uploaded to download.pytorch.org
→ pip/uv resolve platform, download matching wheel
6. Installation (pip/uv unzips the wheel)
.whl → unzipped into site-packages/torch/
torch/_C.cpython-312-x86_64-linux-gnu.so ← the C extension
torch/lib/libtorch.so ← the C++ library
torch/lib/libcudart-*.so ← bundled CUDA runtime
torch/**/*.py ← Python wrappers
7. Import (Python loads the module)
import torch
→ Python finds site-packages/torch/__init__.py
→ __init__.py does: from torch._C import *
→ Python loads _C.cpython-312-x86_64-linux-gnu.so
→ OS dynamic linker loads all .so dependencies
→ torch is ready
Seven layers. When it works, you see one line: import torch. When it breaks, the error usually tells you which layer failed — you just need the vocabulary to read it.
Debugging tools
You’re not going to memorize these. But when something breaks, come back here and find the right tool:
| Command | What it does | When to use it |
|---|---|---|
ldd libfoo.so |
Lists all shared library dependencies and whether they’re found | “cannot open shared object file” errors |
nm -D libfoo.so |
Lists exported and imported symbols | “undefined symbol” errors |
c++filt _ZN3c10... |
Demangles a C++ symbol name to human-readable | Understanding “undefined symbol” errors |
readelf -d libfoo.so |
Shows ELF metadata (dependencies, runpath, etc.) | Deep debugging of library loading |
file libfoo.so |
Shows architecture, format, whether it’s 32/64 bit | “wrong ELF class” or architecture mismatch |
strace python -c "import torch" |
Traces every system call Python makes | Finding exactly which file open fails |
LD_DEBUG=libs python -c "import torch" |
Verbose output from the dynamic linker | Seeing the linker’s search path in action |
ldd --version |
Shows glibc version | manylinux compatibility checking |
nvidia-smi |
GPU info, driver version, max CUDA version | CUDA version issues |
nvcc --version |
Installed CUDA toolkit version | CUDA toolkit vs driver mismatch |
python -c "import torch; print(torch.__config__.show())" |
Torch’s compiled configuration | Checking what torch was built with |
strings libfoo.so | grep GLIBCXX |
Lists GLIBCXX version symbols in a library | “GLIBCXX_X.Y.Z not found” errors |
A debugging workflow
When import something fails with a native error:
-
Read the error. The error type tells you the layer:
ModuleNotFoundError→ Python can’t find the module (layer 7, not a native issue)ImportError: ... undefined symbol→ ABI/version mismatch (layer 2-3)OSError: ... cannot open shared object file→ missing.so(layer 1-2)ImportError: ... GLIBCXX not found→ glibc too old (layer 1)
-
Identify the broken
.so. The error usually names it. -
Check its dependencies.
ldd /path/to/broken.so— look for “not found” entries. -
If a symbol is undefined.
c++filtto demangle it, then figure out which library should provide it. -
If a
.sois missing.find / -name "libwhatever.so*"— it might exist but not be on the search path. -
Check versions.
nvidia-smifor CUDA driver,nvcc --versionfor toolkit,ldd --versionfor glibc. Compare with what the wheel was compiled for (it’s in the filename).
Putting it all together
Here’s the mental model. When you pip install torch and import torch:
pip’s job: find a wheel that matches your Python version, OS, CPU architecture, and glibc. Download it. Unzip it. Put the .py and .so files in site-packages/.
The OS dynamic linker’s job: when Python loads torch/_C.so, find all the other .so files it depends on (libcudart, libcudnn, libnccl, etc.). Load them into memory. Connect all the function references.
Your job: make sure the versions align. The wheel was compiled for a specific CUDA toolkit. The CUDA toolkit must be ≤ your driver’s supported version. The glibc must be ≥ the manylinux tag. The CPU architecture must match.
When everything lines up, import torch takes about 2 seconds and you never think about any of this.
When something’s off by one version, you get an error that looks like it’s in an alien language. But now you know: it’s always one of about five things. Missing .so, version mismatch, ABI incompatibility, missing headers, or wrong platform.
Different nouns for the same verbs. Compile, link, package, install, import. That’s all it is.
This post grew out of a Bazel migration where I kept hitting native dependency issues and realized I’d never properly learned what any of these tools were. If you’re doing a Bazel migration in a Python monorepo with GPU dependencies — or if you just want to understand why pip install sometimes prints 200 lines of C compiler output — I hope this helps.
Previously: Three Ways to Smuggle torch into Bazel — smuggling native deps into a hermetic build. What’s Inside a 40GB ML Training Container — the Docker layer that pins the compatibility matrix. uv and the Zoo of Python Package Tools — the packaging layer above all of this.