In this lecture, we review some popular computer languages commonly used in computational statistics. We want to understand why some languages are faster than others and what are the remedies for the slow languages.
1.1 Poll: Multiple Answer
Which languages have you programmed before?
A. C or C++
B. Fortran
C. Julia
D. Matlab
E. Python
2 Types of computer languages
Compiled languages (low-level languages): C/C++, Fortran, …
Directly compiled to machine code that is executed by CPU
Pros: fast, memory efficient
Cons: longer development time, hard to debug
Interpreted language (high-level languages): R, Matlab, Python, SAS IML, JavaScript, …
Typical execution of a high-level language such as R, Python, and Matlab.
To improve efficiency of interpreted languages such as R or Matlab, conventional wisdom is to avoid loops as much as possible. Aka, vectorize code > The only loop you are allowed to have is that for an iterative algorithm.
When looping is unavoidable, need to code in C, C++, or Fortran. This creates the notorious two language problem.
Success stories: the popular glmnet package in R is coded in Fortran; tidyverse and data.table packages use a lot RCpp/C++.
High-level languages have made many efforts to bring themselves closer to the performance of low-level languages such as C, C++, or Fortran, with a variety levels of success.
Matlab has employed JIT (just-in-time compilation) technology since 2002.
Since R 3.4.0 (Apr 2017), the JIT bytecode compiler is enabled by default at its level 3.
Cython is a compiler system based on Python.
Modern languages such as Julia tries to solve the two language problem. That is to achieve efficiency without vectorizing code.
Julia execution.
4 Gibbs sampler example by Doug Bates
Doug Bates (member of R-Core, author of popular R packages Matrix, lme4, RcppEigen, etc)
As some of you may know, I have had a (rather late) mid-life crisis and run off with another language called Julia.
The task is to create a Gibbs sampler for the density \[
f(x, y) = k x^2 \exp(- x y^2 - y^2 + 2y - 4x), \quad x > 0
\] using the conditional distributions \[
\begin{eqnarray*}
X | Y &\sim& \Gamma \left( 3, \frac{1}{y^2 + 4} \right) \\
Y | X &\sim& N \left(\frac{1}{1+x}, \frac{1}{2(1+x)} \right).
\end{eqnarray*}
\]
4.1 Poll: Multiple choice
Which language will be faster for this Gibbs sampler: R or Julia?
A. R is much faster than Julia
B. R is moderately faster than Julia
C. R is about same speed as Julia
D. R is moderately slower than Julia
E. R is much slower than Julia
4.2 R solution
The RCall.jl package allows us to execute R code without leaving the Julia environment. We first define an R function Rgibbs().
usingRCall# show R informationR"""sessionInfo()"""
RObject{VecSxp}
R version 4.3.2 (2023-10-31)
Platform: aarch64-apple-darwin20 (64-bit)
Running under: macOS Sonoma 14.4.1
Matrix products: default
BLAS: /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib
LAPACK: /Library/Frameworks/R.framework/Versions/4.3-arm64/Resources/lib/libRlapack.dylib; LAPACK version 3.11.0
locale:
[1] C
time zone: America/Los_Angeles
tzcode source: internal
attached base packages:
[1] stats graphics grDevices utils datasets methods base
loaded via a namespace (and not attached):
[1] compiler_4.3.2
# define a function for Gibbs samplingR"""library(Matrix)Rgibbs <- function(N, thin) { mat <- matrix(0, nrow=N, ncol=2) x <- y <- 0 for (i in 1:N) { for (j in 1:thin) { x <- rgamma(1, 3, y * y + 4) # 3rd arg is rate y <- rnorm(1, 1 / (x + 1), 1 / sqrt(2 * (x + 1))) } mat[i,] <- c(x, y) } mat}"""
RObject{ClosSxp}
function (N, thin)
{
mat <- matrix(0, nrow = N, ncol = 2)
x <- y <- 0
for (i in 1:N) {
for (j in 1:thin) {
x <- rgamma(1, 3, y * y + 4)
y <- rnorm(1, 1/(x + 1), 1/sqrt(2 * (x + 1)))
}
mat[i, ] <- c(x, y)
}
mat
}
To generate a sample of size 10,000 with a thinning of 500. How long does it take?
R"""system.time(Rgibbs(10000, 500))"""
RObject{RealSxp}
user system elapsed
11.362 0.772 12.240
4.3 Julia solution
This is a Julia function for the same Gibbs sampler:
usingDistributionsfunctionjgibbs(N, thin) mat =zeros(N, 2) x = y =0.0for i in1:Nfor j in1:thin x =rand(Gamma(3, 1/ (y * y +4))) y =rand(Normal(1/ (x +1), 1/sqrt(2(x +1))))end mat[i, 1] = x mat[i, 2] = yend matend
jgibbs (generic function with 1 method)
Generate the same number of samples. How long does it take?
We see 40-80 fold speed up of Julia over R on this example, with similar coding effort!
5 Comparing C, C++, R, Python, and Julia
To better understand how these languages work, we consider a simple task: summing a vector.
Let’s first generate data: 1 million double precision numbers from uniform [0, 1].
usingRandomRandom.seed!(257) # seedx =rand(1_000_000) # 1 million random numbers in [0, 1)sum(x)
500156.3977288406
In this class, we extensively use package BenchmarkTools.jl for robust benchmarking. It’s the analog of the microbenchmark or bench package in R.
usingBenchmarkTools
5.1 C
We would use the low-level C code as the baseline for copmarison. In Julia, we can easily run compiled C code using the ccall function.
usingLibdlC_code ="""#include <stddef.h>double c_sum(size_t n, double *X) { double s = 0.0; for (size_t i = 0; i < n; ++i) { s += X[i]; } return s;}"""const Clib =tempname() # make a temporary file# compile to a shared library by piping C_code to gcc# (works only if you have gcc installed):open(`gcc -std=c99 -fPIC -O3 -msse3 -xc -shared -o $(Clib * "." * Libdl.dlext) -`, "w") do fprint(f, C_code) end# define a Julia function that calls the C function:c_sum(X :: Array{Float64}) =ccall(("c_sum", Clib), Float64, (Csize_t, Ptr{Float64}), length(X), X)
clang: warning: argument unused during compilation: '-msse3' [-Wunused-command-line-argument]
c_sum (generic function with 1 method)
# make sure it gives same answerc_sum(x)
500156.39772885485
# dollar sign to interpolate array x into local scope for benchmarkingbm =@benchmarkc_sum($x)
BenchmarkTools.Trial: 5650 samples with 1 evaluation.
Range (min … max): 811.625 μs … 1.107 ms┊ GC (min … max): 0.00% … 0.00%
Time (median): 858.041 μs ┊ GC (median): 0.00%
Time (mean ± σ): 880.589 μs ± 43.926 μs┊ GC (mean ± σ): 0.00% ± 0.00%
█▇
▂▂▂▂▂▂▂▁▁▁▁██▅▃▃▃▂▂▂▂▂▂▂▂▂▂▃▂▃▂▃▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁ ▂
812 μs Histogram: frequency by time 1.01 ms <
Memory estimate: 0 bytes, allocs estimate: 0.
# a dictionary to store median runtime (in milliseconds)benchmark_result =Dict() # store median runtime (in milliseconds)benchmark_result["C"] =median(bm.times) /1e6
0.858041
5.2 R, buildin sum
Next we compare to the build in sum function in R, which is implemented using C.
R"""library(bench)library(tidyverse)# interpolate x into R workspacey <- $xrbm_builtin <- bench::mark(sum(y)) %>% print(width = Inf)""";
┌ Warning: RCall.jl: -- Attaching core tidyverse packages ------------------------ tidyverse 2.0.0 --
│ v dplyr 1.1.3 v readr 2.1.4
│ v forcats 1.0.0 v stringr 1.5.0
│ v ggplot2 3.4.4 v tibble 3.2.1
│ v lubridate 1.9.2 v tidyr 1.3.0
│ v purrr 1.0.2
│ -- Conflicts ------------------------------------------ tidyverse_conflicts() --
│ x tidyr::expand() masks Matrix::expand()
│ x dplyr::filter() masks stats::filter()
│ x dplyr::lag() masks stats::lag()
│ x tidyr::pack() masks Matrix::pack()
│ x tidyr::unpack() masks Matrix::unpack()
│ i Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
└ @ RCall /Users/huazhou/.julia/packages/RCall/dDAVd/src/io.jl:172
# A tibble: 1 x 13
expression min median `itr/sec` mem_alloc `gc/sec` n_itr n_gc
<bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt> <dbl> <int> <dbl>
1 sum(y) 1.35ms 1.41ms 702. 0B 0 352 0
total_time result memory time gc
<bch:tm> <list> <list> <list> <list>
1 501ms <dbl [1]> <Rprofmem [0 x 3]> <bench_tm [352]> <tibble [352 x 3]>
# store median runtime (in milliseconds)@rget rbm_builtin # dataframebenchmark_result["R builtin"] =median(rbm_builtin[!, :median]) *1000
1.4116505335550755
5.3 R, handwritten loop
Handwritten loop in R is much slower.
R"""sum_r <- function(x) { s <- 0 for (xi in x) { s <- s + xi } s}library(bench)y <- $xrbm_loop <- bench::mark(sum_r(y)) %>% print(width = Inf)""";
# A tibble: 1 x 13
expression min median `itr/sec` mem_alloc `gc/sec` n_itr n_gc
<bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt> <dbl> <int> <dbl>
1 sum_r(y) 7.4ms 7.73ms 130. 13.1KB 0 65 0
total_time result memory time gc
<bch:tm> <list> <list> <list> <list>
1 501ms <dbl [1]> <Rprofmem [42 x 3]> <bench_tm [65]> <tibble [65 x 3]>
# store median runtime (in milliseconds)@rget rbm_loop # dataframebenchmark_result["R loop"] =median(rbm_loop[!, :median]) *1000
7.727639022050425
5.4 R, Rcpp
Rcpp package provides an easy way to incorporate C++ code in R.
R"""library(Rcpp)cppFunction('double rcpp_sum(NumericVector x) { int n = x.size(); double total = 0; for(int i = 0; i < n; ++i) { total += x[i]; } return total;}')rcpp_sum"""
RObject{ClosSxp}
function (x)
.Call(<pointer: 0x3218908c0>, x)
# A tibble: 1 x 13
expression min median `itr/sec` mem_alloc `gc/sec` n_itr n_gc
<bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt> <dbl> <int> <dbl>
1 rcpp_sum(y) 800us 855us 1159. 2.49KB 0 580 0
total_time result memory time gc
<bch:tm> <list> <list> <list> <list>
1 500ms <dbl [1]> <Rprofmem [1 x 3]> <bench_tm [580]> <tibble [580 x 3]>
# store median runtime (in milliseconds)@rget rbm_rcpp # dataframebenchmark_result["R Rcpp"] =median(rbm_rcpp[!, :median]) *1000
0.8551369828637689
5.5 Python, builtin sum
Built in function sum in Python.
usingPyCallPyCall.pyversion
v"3.10.12"
# get the Python built-in "sum" function:pysum =pybuiltin("sum")bm =@benchmark$pysum($x)
BenchmarkTools.Trial: 125 samples with 1 evaluation.
Range (min … max): 38.618 ms … 41.136 ms┊ GC (min … max): 0.00% … 0.00%
Time (median): 39.884 ms ┊ GC (median): 0.00%
Time (mean ± σ): 39.921 ms ± 462.277 μs┊ GC (mean ± σ): 0.00% ± 0.00%
▆█▃▂▂ ▅▆ ▃
▄▁▁▁▄▁▁▁▄▁▅▁▁▄▁▄▇▄▁▄▄▇▇▇▇█████▅▇████▄█▇▄▄▇▄▄▇▅▁▇█▄▁▁▁▇▁▄▄▅▁▄ ▄
38.6 ms Histogram: frequency by time 41 ms <
Memory estimate: 240 bytes, allocs estimate: 6.
# store median runtime (in miliseconds)benchmark_result["Python builtin"] =median(bm.times) /1e6
39.884417
5.6 Python, handwritten loop
py"""def py_sum(A): s = 0.0 for a in A: s += a return s"""sum_py = py"py_sum"bm =@benchmark$sum_py($x)
BenchmarkTools.Trial: 103 samples with 1 evaluation.
Range (min … max): 46.605 ms … 50.299 ms┊ GC (min … max): 0.00% … 0.00%
Time (median): 48.906 ms ┊ GC (median): 0.00%
Time (mean ± σ): 48.963 ms ± 494.630 μs┊ GC (mean ± σ): 0.00% ± 0.00%
▅▆▂ █▅▂▂ ▂ ▂▂
▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▁▁▄▅█▇▇███▅████▇██▇██▄▁▅▅▄▁▄▄▅▄▁▅ ▄
46.6 ms Histogram: frequency by time 50.1 ms <
Memory estimate: 240 bytes, allocs estimate: 6.
# store median runtime (in miliseconds)benchmark_result["Python loop"] =median(bm.times) /1e6
48.90625
5.7 Python, numpy
Numpy is the high-performance scientific computing library for Python.
# bring in sum function from Numpy numpy_sum =pyimport("numpy")."sum"
PyObject <function sum at 0x324f7f630>
bm =@benchmark$numpy_sum($x)
BenchmarkTools.Trial: 10000 samples with 1 evaluation.
Range (min … max): 160.708 μs … 299.500 μs┊ GC (min … max): 0.00% … 0.00%
Time (median): 176.250 μs ┊ GC (median): 0.00%
Time (mean ± σ): 177.161 μs ± 3.582 μs┊ GC (mean ± σ): 0.00% ± 0.00%
▂█▇▅▂▃▄▂
▂▂▁▁▁▂▂▁▁▁▁▁▁▁▁▂▂▂▂▂▂▂▁▂▂▂▂▃█████▅▄▇███▆▄▃▃▃▄▃▃▂▂▂▂▂▂▂▂▂▂▂▂▂▂ ▃
161 μs Histogram: frequency by time 189 μs <
Memory estimate: 240 bytes, allocs estimate: 6.
# store median runtime (in miliseconds)benchmark_result["Python numpy"] =median(bm.times) /1e6
0.17625
Numpy performance is on a par with Julia build-in sum function. Both are about 3 times faster than C, possibly because of more cache-friendly recursive mapreduce implementation.
5.8 Julia, builtin sum
@time, @elapsed, @allocated macros in Julia report run times and memory allocation.
@timesum(x) # no compilation time after first run
0.000181 seconds (1 allocation: 16 bytes)
500156.3977288406
For more robust benchmarking, we use BenchmarkTools.jl package.
C, R builtin, and RCpp are the baseline C or C++ performance (gold standard).
Python builtin and Python loop are >50 fold slower than C because the loop is interpreted.
R loop is about 10 times slower than C and indicates the performance of JIT bytecode generated by its compiler package (turned on by default since R v3.4.0 (Apr 2017)).
Julia loop is close to C performance, because Julia code is JIT compiled.
Julia builtin and Python numpy are 4-5 fold faster than C because of more cache-friendly algorithm (recursive mapreduce).
@turbo (SIMD) and @tturbo (multithreading+SIMD), offered by the LoopVectorization.jl package. yields the top performance on this machine.
6 Take home message for computational scientists
High-level language (R, Python, Matlab) programmers should be familiar with existing high-performance packages. Don’t reinvent wheels.
R: RcppEigen, tidyverse, …
Python: numpy, scipy, …
In most research projects, looping is unavoidable. Then we need to use a low-level language.
R: Rcpp, …
Python: Cython, …
In this course, we use Julia, which circumvents the two language problem. So we can spend more time on algorithms, not on juggling \(\ge 2\) languages.