Skip to content
47 changes: 32 additions & 15 deletions lectures/numba.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,17 @@ Compare speed with and without Numba when the sample size is large.
Here is one solution:

```{code-cell} ipython3
n = 1_000_000
rng = np.random.default_rng()
u_draws = rng.uniform(size=n)
v_draws = rng.uniform(size=n)

@jit
def calculate_pi(n=1_000_000):
def calculate_pi(u_draws, v_draws):
n = len(u_draws)
count = 0
for i in range(n):
u, v = np.random.uniform(0, 1), np.random.uniform(0, 1)
u, v = u_draws[i], v_draws[i]
d = np.sqrt((u - 0.5)**2 + (v - 0.5)**2)
if d < 0.5:
count += 1
Expand All @@ -471,12 +477,12 @@ Now let's see how fast it runs:

```{code-cell} ipython3
with qe.Timer():
calculate_pi()
calculate_pi(u_draws, v_draws)
```

```{code-cell} ipython3
with qe.Timer():
calculate_pi()
calculate_pi(u_draws, v_draws)
```

If we switch off JIT compilation by removing `@jit`, the code takes around

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coded timing number is likely stale now. The old calculate_pi drew randoms inside the loop (np.random.uniform(...) calls); the new version just indexes into pre-drawn arrays. Removing the RNG calls from the loop body changes the no-JIT Python baseline substantially, so the "~150×" ratio this sentence quotes — and the "2 orders of magnitude" claim two lines down — were measured against the old code and are probably no longer accurate.

More broadly, hard-coding exact multipliers into the lecture is fragile: they drift silently on every NumPy/Numba/hardware bump. Recommend re-measuring under the new code and softening to qualitative language, e.g. "more than two orders of magnitude faster on our machine" / "dramatically faster", rather than a precise figure.

Expand Down Expand Up @@ -550,10 +556,13 @@ p, q = 0.1, 0.2 # Prob of leaving low and high state respectively
Here's a pure Python version of the function

```{code-cell} ipython3
def compute_series(n):
n = 1_000_000
rng = np.random.default_rng()
U = rng.uniform(0, 1, size=n)

def compute_series(n, U):
x = np.empty(n, dtype=np.int64)
x[0] = 1 # Start in state 1
U = np.random.uniform(0, 1, size=n)
for t in range(1, n):
current_x = x[t-1]
if current_x == 0:
Comment thread
HumphreyYang marked this conversation as resolved.
Expand All @@ -567,8 +576,7 @@ Let's run this code and check that the fraction of time spent in the low
state is about 0.666

```{code-cell} ipython3
n = 1_000_000
x = compute_series(n)
x = compute_series(n, U)
print(np.mean(x == 0)) # Fraction of time x is in state 0
```

Expand All @@ -578,7 +586,7 @@ Now let's time it:

```{code-cell} ipython3
with qe.Timer():
compute_series(n)
compute_series(n, U)
```

Next let's implement a Numba version, which is easy
Expand All @@ -590,15 +598,15 @@ compute_series_numba = jit(compute_series)
Let's check we still get the right numbers

```{code-cell} ipython3
x = compute_series_numba(n)
x = compute_series_numba(n, U)
print(np.mean(x == 0))
```

Let's see the time

```{code-cell} ipython3
with qe.Timer():
compute_series_numba(n)
compute_series_numba(n, U)
```

This is a nice speed improvement for one line of code!
Expand Down Expand Up @@ -636,11 +644,17 @@ For the size of the Monte Carlo simulation, use something substantial, such as
Here is one solution:

```{code-cell} ipython3
n = 1_000_000
rng = np.random.default_rng()
u_draws = rng.uniform(size=n)
v_draws = rng.uniform(size=n)
Comment thread
HumphreyYang marked this conversation as resolved.
Outdated

@jit(parallel=True)
def calculate_pi(n=1_000_000):
def calculate_pi(u_draws, v_draws):
n = len(u_draws)
count = 0
for i in prange(n):
u, v = np.random.uniform(0, 1), np.random.uniform(0, 1)
u, v = u_draws[i], v_draws[i]
d = np.sqrt((u - 0.5)**2 + (v - 0.5)**2)
if d < 0.5:
count += 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this avoids the thread-safety issue, but the timing comparison is no longer quite apples-to-apples because the random draws have been moved outside the timed function.

Personally, I think we can generate the draws in the first solution by moving

n = 1_000_000
rng = np.random.default_rng()
u_draws = rng.uniform(size=n)
v_draws = rng.uniform(size=n)

to line 457, and modifying the function in the code block starting at line 457 (solution to exercise 1) to take u_draws and v_draws, like the function here.

In this case, we would have a fair timing comparison.

Please let me know what you think!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @mmcky, I think this is an example where we need to make a tough choice between keeping the legacy API for simplicity and using the new rng API with some trade-offs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @HumphreyYang I agree with you. I have added some additional notes below regarding double checking nopython mode as well.

Expand All @@ -653,12 +667,12 @@ Now let's see how fast it runs:

```{code-cell} ipython3
with qe.Timer():
calculate_pi()
calculate_pi(u_draws, v_draws)
```

```{code-cell} ipython3
with qe.Timer():
calculate_pi()
calculate_pi(u_draws, v_draws)
```

By switching parallelization on and off (selecting `True` or
Expand Down Expand Up @@ -750,6 +764,9 @@ $$

Using this fact, the solution can be written as follows.

Note that random draws are kept inside the inner loop rather than pre-allocated,
to avoid creating large shock arrays of size `M * n`.

Comment thread
HumphreyYang marked this conversation as resolved.
Outdated

```{code-cell} ipython3
M = 10_000_000
Expand Down
Loading