The stochastic logistic population model described in this blog post has become my default exercise when I'm exploring a new programming language (Racket, F#, Rust). I ended that Rust post by noting that I was interested in Rust as a modern alternative for scientific computing and thought it would be a good learning exercise to re-write small legacy Fortran programs in Rust. In the process of looking for Fortran programs to translate to Rust, though, I found myself becoming more interested in the idea of learning Fortran than Rust, particularly after learning about the efforts to improve the tooling around Fortran (e.g., here and here). So, here we are...exploring Fortran via the stochastic population model exercise.

Standalone Fortran Program

First, I wrote a standalone Fortran program of the stochastic population model. The main thing that tripped me up was reading arguments from the command line. I initialized a 1D array for holding the values of the arguments.

character(len=20), dimension(5) :: args

I initially missed the len argument for character and was confused by the results I was getting until I realized that the program was only reading the first character provided for each argument.

We can straightforwardly fill the args array with a loop.

do i=1,command_argument_count()
  call get_command_argument(i, args(i))
end do

But the syntax for converting the arguments from strings to the correct types is unusual.

read(args(1), *) t

t is the first value in args (indexed with args(1)). The * indicates the default format of the value, which the compiler infers is an integer because that is the type of t.

Fortran Subroutines

I also created a file that only contains subroutines for calling from R. Two of the subroutines (rstduniform and rnormal) are mostly the same as from the main program, but the names are changed because the R help for the .Fortran function recommended not using underscores in the subroutine names and the type of several parameters is double precision, not real, for compatibility with R. One of the subroutines, rnorm, is only included as a test of rnormal.

subroutine rnorm(reps, mu, sigma, arr)
  implicit none
  integer :: i
  integer, intent(in) :: reps
  double precision, intent(in) :: mu, sigma
  double precision, intent(inout) :: arr(reps)
  do i=1,reps
    call rnormal(mu, sigma, arr(i))
  end do
end subroutine rnorm

The arr parameter is declared as intent(inout) because on the R side we will pass in a vector that is filled on the Fortran side and then returned again to R. There are performance pitfalls with this approach if you are passing large vectors back-and-forth between Fortran and R, but those can be partly mitigated with the use of the dotCall64 package.

We compile this file into a shared object for use in R with

R CMD SHLIB stochastic-logistic-subroutines.f90

Calling Fortran from R

In the R code, we load the shared object with

dyn.load("stochastic-logistic-subroutines.so")

To test that the rnormal subroutine is giving reasonable results, we call the rnorm subroutine as the first argument to .Fortran. In this example, we draw 1 million random variates from a normal distribution with a mean of 7.8 and standard deviation of 8.3.

> n = 1000000L
> rnorm_result <- .Fortran("rnorm", n, 7.8, 8.3, vector("numeric", n))
# .Fortran returns a list; 4th element of list in this case contains the vector
> mean(rnorm_result[[4]])
[1] 7.798748
> sd(rnorm_result[[4]])
[1] 8.302705

Following the original blog post, we benchmark the R, Rcpp, and Fortran versions.

> library(microbenchmark)
> microbenchmark(
    Rcpp = logmodc(t, yinit, r, k, thetasd),
    Fortran = .Fortran("logmodf", t, yinit, r, k, thetasd, vector("numeric", t)),
    R = logmodr(t, yinit, r, k, thetasd),
    times = 500L
  )
Unit: microseconds
    expr    min      lq     mean  median      uq     max neval
    Rcpp 13.235 14.6260 17.11903 15.5280 16.8100  86.612   500
 Fortran 21.950 22.8965 26.76053 23.8760 25.3160  72.528   500
       R 56.994 58.8775 67.25860 59.9085 62.3655 243.965   500

The original blog post (from 2017) observed a 19x speed up of the Rcpp version over the R version. Here we only get a 4x improvement presumably related to performance improvements in R. Our Fortran version is 2.5x faster than the R version.

Again, following the original blog post (with a slight modification), we benchmark running multiple simulations.

> rseq <- seq(1.1, 2.2, length.out = 10)
> microbenchmark(
    Rcpp = lapply(rseq, function(x) logmodc(t, yinit, x, k, thetasd)),
    Fortran = lapply(rseq, function(x) .Fortran("logmodf", t, yinit, x, k, thetasd, vector("numeric", t))),
    R = lapply(rseq, function(x) logmodr(t, yinit, x, k, thetasd)),
    times = 500L
  )
Unit: microseconds
    expr     min       lq     mean   median       uq      max neval
    Rcpp 159.578 166.4295 214.4782 198.2240 202.9475 8265.555   500
 Fortran 233.397 239.9620 264.1335 256.8515 262.1665 2994.857   500
       R 621.029 635.1115 676.0114 665.7525 674.2735 2952.635   500

The relative performance gains for multiple simulations are similar to the single simulation.

Conclusion

This post doesn't make a resounding case for using Fortran over C++ for speeding up simulations in R. The C++ code is shorter and runs faster. My C++ avoidance has centered on the complexity of the language. Perhaps there is a simpler subset of C++ that would go a long way towards speeding up my R code. However, this post makes the case that Fortran is still easier to learn than C++ for physics simulations.