- The Big Picture
- Creating an Isolated Environment
- A Hidden Python Alias Bypassed Conda
- Writing the First Numba Experiment
- Correcting the Compiler Cache
- Standalone Script Results
- Turning the Experiment into a JupyterLab Tutorial
- JupyterLab Benchmark Results
- Inspecting What Numba Compiled
- What I Learned
- Why This Matters for Browser-Based Tutorials
- References
My interest in Numba began with something more ambitious: the arrival of a working version of Numba inside the web browser. The possibility of publishing scientific Python tutorials that readers could execute without installing Python, configuring Conda, or connecting to a remote notebook server immediately caught my attention.
Before attempting the browser version, however, I wanted to understand ordinary Numba on my Debian machine. That meant learning what Numba actually does, how its just-in-time compilation behaves, how caching changes startup time, and what kind of speed improvement I could measure for myself.
This first lab turned into more than a performance test. It also uncovered a subtle Python alias that was bypassing my Conda environments, gave me a practical demonstration of compiled-code caching, and produced my first complete Numba tutorial in JupyterLab.
The Big Picture
Python is wonderfully expressive, but an ordinary Python loop is executed one operation at a time by the CPython interpreter. That interpreter overhead becomes significant when a loop performs millions of numerical operations.
Numba is a just-in-time compiler designed primarily for numerical Python and NumPy code. The @njit decorator tells Numba to analyze a supported function, determine the concrete types being used, lower the function through its compiler pipeline, and use LLVM to generate optimized machine code.
The important distinction is that Numba does not make every Python program faster. It is particularly effective for numerical loops and array-oriented calculations that fit the subset of Python and NumPy it supports. NumPy operations already implemented in optimized native libraries may not receive the same dramatic benefit.
The best candidates are often explicit loops that would otherwise spend most of their time inside the Python interpreter.
Creating an Isolated Environment
I used Conda to create a dedicated environment named numba-lab. Keeping the experiment outside the Anaconda base environment protected the rest of my Python setup and made the lab easier to reproduce.
conda create --name numba-lab \
--override-channels -c conda-forge \
python=3.13 numba numpy matplotlib jupyterlab
The environment included everything needed for the tutorial:
- Python for the language runtime
- Numba and its LLVM-related dependencies
- NumPy for numerical arrays
- Matplotlib for visualization
- JupyterLab for building the notebook
The installation completed normally, and I activated it with:
conda activate numba-lab
At that point, the prompt changed from (base) to (numba-lab). Everything appeared correct—until I checked the versions.
A Hidden Python Alias Bypassed Conda
My first version check reported:
Python: 3.14.6
Numba: 0.65.1
NumPy: 2.4.6
Those were not the versions installed in the new environment. The discrepancy led to an important diagnostic command:
type -a python
The result revealed the problem:
python is aliased to `/home/lvydvy/anaconda3/bin/python'
python is /home/lvydvy/anaconda3/envs/numba-lab/bin/python
The shell alias took precedence over the environment’s PATH. Conda had activated numba-lab, but every command named python was still being redirected to the fixed interpreter in Anaconda’s base directory.
Calling the environment’s interpreter explicitly confirmed that the new environment itself was correct:
"$CONDA_PREFIX/bin/python" -c "import sys, numba, numpy; print(sys.executable); print(sys.version.split()[0]); print(numba.__version__); print(numpy.__version__)"
That produced the intended versions:
Interpreter: /home/lvydvy/anaconda3/envs/numba-lab/bin/python
Python: 3.13.15
Numba: 0.67.0
NumPy: 2.5.2
I removed the alias from the current shell with:
unalias python
hash -r
I also commented out the persistent alias in ~/.bashrc:
#alias python='/home/lvydvy/anaconda3/bin/python'
This did not remove Anaconda. It simply allowed Conda to select the correct Python interpreter for whichever environment was active.
This was one of the most valuable lessons in the lab: an activated environment name in the prompt does not, by itself, prove that the expected interpreter is running. type -a python, sys.executable, and $CONDA_PREFIX provide much stronger evidence.
Writing the First Numba Experiment
I created a project directory and a script named first_numba.py:
mkdir -p ~/Projects/numba-lab
cd ~/Projects/numba-lab
nano first_numba.py
The experiment calculated the sum of the squares of ten million floating-point values. It implemented the same loop twice: once as an ordinary Python function and once with Numba.
from time import perf_counter
import numpy as np
from numba import njit
def python_sum_squares(values):
total = 0.0
for value in values:
total += value * value
return total
@njit(cache=True)
def numba_sum_squares(values):
total = 0.0
for value in values:
total += value * value
return total
values = np.linspace(0.0, 1.0, 10_000_000)
start = perf_counter()
python_result = python_sum_squares(values)
python_time = perf_counter() - start
start = perf_counter()
first_numba_result = numba_sum_squares(values)
first_numba_time = perf_counter() - start
start = perf_counter()
second_numba_result = numba_sum_squares(values)
second_numba_time = perf_counter() - start
print(f"Python result: {python_result:.4f}")
print(f"Numba result: {second_numba_result:.4f}")
print()
print(f"Regular Python: {python_time:.6f} seconds")
print(f"First Numba call: {first_numba_time:.6f} seconds")
print(f"Second Numba call: {second_numba_time:.6f} seconds")
print()
print(f"Warm-call speedup: {python_time / second_numba_time:.1f}x")
The benchmark intentionally measured three different situations:
- Ordinary interpreted Python
- Numba’s first call, when compilation or cache loading occurs
- Numba’s second call, when compiled code is already available in memory
Correcting the Compiler Cache
Before the interpreter alias was completely cleared from the running shell, Numba generated cache files tagged for Python 3.14. They appeared under __pycache__ with py314 in their names.
Because the project was supposed to use Python 3.13, I removed only those generated cache files and ran the script again with the verified environment interpreter.
Numba then produced the correct files:
first_numba.numba_sum_squares-16.py313.1.nbc
first_numba.numba_sum_squares-16.py313.nbi
The .nbi file stores cache-index information, while the .nbc file contains the cached compiled representation used by Numba. These are disposable generated artifacts; the Python source remains the authoritative program.
Standalone Script Results
The first verified run under Python 3.13 produced:
| Measurement | Time |
|---|---|
| Regular Python | 1.190110 seconds |
| First Numba call | 1.212628 seconds |
| Second Numba call | 0.008886 seconds |
| Warm-call speedup | 133.9× |
The first Numba call had to compile the function and execute it, so its time was close to the ordinary Python result. The second call reused the machine code already loaded in memory and completed the same loop in fewer than nine milliseconds.
I then launched the script as a new Python process. Because cache=True had stored reusable compilation data, the first Numba call became much faster:
| Measurement | Time |
|---|---|
| Regular Python | 1.183045 seconds |
| First Numba call using disk cache | 0.174827 seconds |
| Second Numba call | 0.008877 seconds |
| Warm-call speedup | 133.3× |
The disk cache reduced the new process’s first-call cost by roughly seven times compared with the uncached compilation run.
Loading cached material still took longer than calling code already resident in memory, which is why the second Numba call remained the fastest.
Turning the Experiment into a JupyterLab Tutorial
The terminal program proved that Numba worked, but my larger goal was to create something suitable for teaching and eventually publishing.
From the same activated environment and project directory, I started JupyterLab:
jupyter lab
Jupyter opened the project at localhost, and I created numba_tutorial_01.ipynb using the Python 3 kernel.
Before doing anything else, I verified the notebook’s interpreter:
import sys
import numba
import numpy as np
print("Interpreter:", sys.executable)
print("Python:", sys.version.split()[0])
print("Numba:", numba.__version__)
print("NumPy:", np.__version__)
The notebook correctly reported:
Interpreter: /home/lvydvy/anaconda3/envs/numba-lab/bin/python
Python: 3.13.15
Numba: 0.67.0
NumPy: 2.5.2
Inside the notebook I used @njit without cache=True. A notebook kernel retains the compiled function in memory while the kernel is running, whereas persistent disk caching was more naturally and clearly demonstrated by the standalone .py script.
The notebook included explanatory Markdown, both function definitions, the timing experiment, a Matplotlib bar chart, an interpretation of the results, and an inspection of Numba’s compiled signature.
JupyterLab Benchmark Results
The notebook produced the following clean comparison:
| Measurement | Time |
|---|---|
| Regular Python | 1.239615 seconds |
| First Numba call, including compilation | 1.105175 seconds |
| Second Numba call | 0.010251 seconds |
| Warm-call speedup | 120.9× |
Both implementations returned the same displayed result:
3333333.5000
The chart made the difference immediately visible. The regular Python and first-call Numba bars occupied most of the vertical scale, while the warmed Numba bar was only a thin green line near zero.
Inspecting What Numba Compiled
Numba creates specialized implementations based on the input types it observes. After calling the accelerated function, I inspected its compiled signatures:
numba_sum_squares.signatures
The notebook returned:
[(Array(float64, 1, 'C', False, aligned=True),)]
That signature describes the input used for this specialization:
float64: each item is a 64-bit floating-point value1: the array has one dimension'C': the data is stored contiguously in C-style orderFalse: the array is writable rather than read-onlyaligned=True: the memory is aligned appropriately for efficient CPU access
This helped clarify what @njit actually does. Numba did not create an abstract acceleration layer for every imaginable Python object. It compiled a concrete optimized implementation for the numerical array type presented during the first call.
Passing a materially different input type may cause Numba to compile another specialization.
What I Learned
This lab produced several lessons that will carry forward:
- Verify the interpreter, not just the prompt. A shell alias can silently override an activated Conda environment.
- The first Numba call is different. It includes compilation or cache-loading work and should not be confused with steady-state execution.
- Warm performance is where Numba shines. The verified Jupyter experiment improved this numerical loop by 120.9 times.
- Disk cache and memory reuse are separate layers.
cache=Truehelps later processes, but code already loaded in the current process remains faster. - Correctness still comes first. The Python and Numba results were compared before interpreting the speedup.
- Numba specializes by type. The compiled signature showed precisely which NumPy array representation LLVM had optimized.
- Numba is a targeted tool, not universal magic. It is most compelling for supported numerical loops and repeated computations whose savings justify compilation.
Why This Matters for Browser-Based Tutorials
The experiment began because Numba can now be demonstrated inside a browser-based JupyterLite environment through WebAssembly.
Understanding normal Numba first gives me a baseline. I now know what @njit means, why the first call behaves differently, how type specialization works, and what numbers to compare when the same experiment eventually runs in a browser.
The longer-term attraction is clear. I could publish a scientific Python tutorial that readers open with a link and execute on their own computers without installing Anaconda, creating an environment, or depending on a remote Python server.
The normal Debian environment remains the right place to develop and verify the lesson; the browser becomes a highly accessible way to distribute it.
My next normal Numba experiment will estimate π with a Monte Carlo simulation. It will combine repeated numerical work with a visual plot of points falling inside and outside a circle. After that, I will be ready to reproduce the core idea in browser-based Numba and begin exploring genuinely executable blog tutorials.