From 1.24 Seconds to 0.010: My First Numba Lab on Debian

I installed Numba on Debian, fixed a Python alias that bypassed Conda, explored compilation and caching, and measured a 120.9× speedup in JupyterLab.

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:

  1. Ordinary interpreted Python
  2. Numba’s first call, when compilation or cache loading occurs
  3. 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:

MeasurementTime
Regular Python1.190110 seconds
First Numba call1.212628 seconds
Second Numba call0.008886 seconds
Warm-call speedup133.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:

MeasurementTime
Regular Python1.183045 seconds
First Numba call using disk cache0.174827 seconds
Second Numba call0.008877 seconds
Warm-call speedup133.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:

MeasurementTime
Regular Python1.239615 seconds
First Numba call, including compilation1.105175 seconds
Second Numba call0.010251 seconds
Warm-call speedup120.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 value
  • 1: the array has one dimension
  • 'C': the data is stored contiguously in C-style order
  • False: the array is writable rather than read-only
  • aligned=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:

  1. Verify the interpreter, not just the prompt. A shell alias can silently override an activated Conda environment.
  2. The first Numba call is different. It includes compilation or cache-loading work and should not be confused with steady-state execution.
  3. Warm performance is where Numba shines. The verified Jupyter experiment improved this numerical loop by 120.9 times.
  4. Disk cache and memory reuse are separate layers. cache=True helps later processes, but code already loaded in the current process remains faster.
  5. Correctness still comes first. The Python and Numba results were compared before interpreting the speedup.
  6. Numba specializes by type. The compiled signature showed precisely which NumPy array representation LLVM had optimized.
  7. 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.

References