Faster Estimation: Backends, Threads, and Large Draws

knitr::opts_chunk$set(
  collapse = TRUE,
  warning = FALSE,
  message = FALSE,
  comment = "#>"
)
library(logitr)

Mixed logit (MXL) models are estimated with maximum simulated likelihood, which repeatedly evaluates the log-likelihood over a set of random draws. For models with many random parameters or many draws, this can be slow.

By default, mixed logit models are estimated with a fast compiled, multi- threaded backend, so you do not need to do anything to get good performance. This vignette explains what that backend is and the arguments that control it:

These change only how the log-likelihood is computed, not what is computed: all options give the same estimates to floating-point precision.

The compiled backend (backend)

For mixed logit models, logitr() uses backend = "cpp" by default: a compiled C++ implementation of the log-likelihood and its analytic gradient. You do not need to do anything to get it — the model below already uses it:

model <- logitr(
  data     = yogurt,
  outcome  = "choice",
  obsID    = "obsID",
  panelID  = "id",
  pars     = c("price", "feat", "brand"),
  randPars = c(feat = "n")
)

The "cpp" backend supports all mixed logit model types: preference and willingness-to-pay (WTP) space, uncorrelated and correlated heterogeneity, and normal ("n"), log-normal ("ln"), and censored-normal ("cn") parameter distributions. It is typically about 4 times faster than the native R implementation.

To use the native R implementation instead, set backend = "cpu". It returns the same coefficients, standard errors, and log-likelihood as "cpp" to floating-point precision. The main reason to choose it is exact bit-reproducibility (see the section on numThreads below). Multinomial logit (MNL) models always use the R implementation, since they are already fast.

Because it includes compiled code, installing the development version of {logitr} from source requires a C++ compiler (see the installation instructions). Installing the released version from CRAN does not, since CRAN provides pre-built binaries.

Multithreading (numThreads)

The "cpp" backend processes the random draws in parallel across CPU cores. Since the draws are independent, this is an exact parallelization that scales well with the number of cores. You can control the number of threads directly:

model <- logitr(
  data       = yogurt,
  outcome    = "choice",
  obsID      = "obsID",
  panelID    = "id",
  pars       = c("price", "feat", "brand"),
  randPars   = c(feat = "n"),
  numDraws   = 500,
  numThreads = 4
)

By default (numThreads = NULL), all but one of the available cores are used, except when running a parallel multistart (numMultiStarts > 1): in that case a single thread is used per model so that the cores go to the multistart instead of being oversubscribed by nested parallelism. Set numThreads = 1 to disable threading entirely.

Because threading uses a parallel reduction, the summation happens in a non-deterministic order, so results are not bit-identical across runs — they differ only at the level of floating-point rounding (around 1e-12), far below the optimization tolerance. If you need exactly reproducible results, set numThreads = 1 (or backend = "cpu").

The table below shows the speedup for evaluating the mixed logit log-likelihood and gradient once, for a panel model with random parameters, as the number of draws grows. Speedups are relative to the native R backend, measured on a 10-core machine (using 9 threads for the multithreaded column). Notice that the multithreaded speedup grows with the number of draws, because larger draw counts give the threads more work to distribute.

| Draws | cpu | cpp (1 thread) | cpp (multithreaded) | |-------:|:-----:|:----------------:|:---------------------:| | 100 | 1x | ~4x | ~21x | | 500 | 1x | ~4x | ~27x | | 2,000 | 1x | ~4x | ~27x | | 10,000 | 1x | ~4x | ~33x |

You can reproduce a version of this comparison on your own machine with the bench/perf_compare.R script in the package's GitHub repository.

Large draw counts (numDrawsBatch)

The compiled "cpp" backend (the default) is memory-flat in the number of draws, so it handles very large draw counts without any special settings. The numDrawsBatch argument is only relevant when you use the native R backend (backend = "cpu"), which by default stores intermediate quantities for every draw at once, so its memory grows with numDraws. For very large draw counts that can exhaust memory. Setting numDrawsBatch streams the draws in batches, keeping peak memory bounded by the batch size rather than the total number of draws:

model <- logitr(
  data          = yogurt,
  outcome       = "choice",
  obsID         = "obsID",
  panelID       = "id",
  pars          = c("price", "feat", "brand"),
  randPars      = c(feat = "n"),
  numDraws      = 10000,
  numDrawsBatch = 500,
  backend       = "cpu"
)

By default (numDrawsBatch = NULL), the "cpu" backend streams automatically only when the draws would otherwise exceed an internal memory budget, so typical models are unaffected and use the faster non-streaming path.

What about GPUs?

logitr does not provide a GPU backend. For the models and dataset sizes typical of choice modeling, the compiled, multithreaded CPU backend is already very fast, and benchmarking shows that a GPU offers little benefit for this kind of computation (the log-likelihood is dominated by a "skinny" matrix multiplication and irregular segment sums, which are memory-bound rather than compute-bound) — and on integrated GPUs it can even be slower than the CPU. A GPU only tends to help on a dedicated NVIDIA GPU with a very large dataset.

If you specifically need GPU-accelerated mixed logit estimation for very large problems, the Python package xlogit is purpose-built for it and supports CUDA GPUs directly.

Summary



Try the logitr package in your browser

Any scripts or data that you put into this service are public.

logitr documentation built on July 6, 2026, 5:11 p.m.