Skip to contents

NLPstudio 1.2.0 reorganized how the package uses your CPU. This vignette explains the model in full: which thread pools exist in an R session, who controls each one, what NLPstudio does by default, and how to tune or bound parallelism on shared machines. It also states plainly which parts of NLPstudio are thin wrappers over quanteda and which parts are the package’s own machinery, so you always know where the work actually happens.

The three thread pools in every R session

Any R process running NLPstudio can be executing parallel code from three independent sources, each with its own switch:

Layer Used by Default Controlled with
quanteda’s TBB pool tokens(), tokens_ngrams(), tokens_compound(), tokens_lookup(), dfm(), textstat_simil()/dist(), and every NLPstudio verb wrapping them all cores quanteda::quanteda_options(threads = ) or the per-call threads argument of NLPstudio verbs
BLAS/LAPACK dense matrix products - e.g. the likelihood metrics in evaluate_topic_model() (theta %*% phi), stability cosine similarity all cores with OpenBLAS/Accelerate/MKL; 1 with reference BLAS RhpcBLASctl::blas_set_num_threads(), or OPENBLAS_NUM_THREADS / VECLIB_MAXIMUM_THREADS / MKL_NUM_THREADS / OMP_NUM_THREADS before R starts
data.table’s OpenMP team grouping, joins, ordering inside all tabular outputs half the logical cores data.table::setDTthreads()

On top of these thread pools there is a fourth, process-level tier: separate R worker processes (PSOCK clusters). Since 1.2.0 NLPstudio uses worker processes only where they genuinely pay: external spaCy pipelines (parse_corpus()), I/O-bound JSON ingestion (from_json_to_df()), and fitting independent topic models in a grid (select_k_topics(), assess_topic_stability()).

What changed in 1.2.0, and why

Up to 1.1.1 the corpus verbs (tokenize_corpus(), lookup_tokens(), ngram_tokens(), compound_tokens(), reshape_corpus(), summarize_corpus(), calculate_readability()) split the input into chunks and scattered them over a PSOCK cluster. That design predates quanteda’s internal parallelism; today the C++ core those verbs call is already multithreaded, so the chunk layer paid cluster startup, per-chunk serialization, and two-to-three times the peak memory to re-implement a parallelism quanteda ships for free - and each worker could spawn its own thread pools on top, oversubscribing the machine (ncores workers times threads per worker times a BLAS pool each).

The benchmark that settled it (committed baseline, 12-core machine): with the old design, tokenize_corpus(corp, ncores = 4) on a 200-document corpus took about 1.47 seconds against 0.089 sequential - sixteen times slower - because serialization dominated. The 1.2.0 verbs make one quanteda call inside quanteda’s own thread pool; the same operation completed in the sequential time with 30-50% fewer allocations, and the deprecated chunk-based arguments now warn.

library(NLPstudio)

toks <- tokenize_corpus(corp)               # quanteda threads, session default
toks <- tokenize_corpus(corp, threads = 4)  # bounded for this call only

threads = NULL (the default) leaves the session-wide quanteda_options("threads") untouched - quanteda already defaults to all cores, so the default is parallel. Passing an integer scopes the pool for that call and restores the previous setting on exit. The old ncores/nchunks/socket arguments are deprecated: supplying them raises a classed warning (NLPstudio_deprecated) and maps ncores to threads.

Two verbs deserve a note. calculate_similarity()/calculate_distance() always delegated threading to quanteda, but their old ncores default of 1 silently throttled quanteda to a single thread; the new default respects the session setting. And stem_tokens()/singularize_tokens() operate on the vocabulary (unique types), not on documents - after vectorizing the rule set the work is far below any cluster’s startup cost, so they are now sequential by design.

Worker processes: where they remain, and their hygiene contract

Process-level parallelism survives exactly where a worker does something a thread cannot:

  • parse_corpus() drives spaCy, an external single-threaded pipeline - more worker processes genuinely mean more parsing throughput (watch RAM).
  • from_json_to_df() is I/O- plus C++-parsing-bound across many files.
  • select_k_topics() and assess_topic_stability() fit independent models per K or per seed; each fit runs inside a backend’s own C code and parallelizes cleanly across processes.

Every PSOCK cluster NLPstudio creates now enforces a thread-hygiene contract: each worker caps all three thread pools to one (quanteda_options(threads = 1), data.table::setDTthreads(1), and BLAS capped through environment variables the workers inherit at spawn, plus RhpcBLASctl when installed). ncores = 4 therefore uses four CPUs, not four times the machine. data.table would have throttled itself in forked children, but not in PSOCK workers; and an unconstrained BLAS grabbing all cores in every worker was half of what used to saturate machines during parallel grid searches.

Since 1.2.0 the grid orchestrators also ship the corpus to each worker exactly once. Previously the per-task closures captured the calling frame, so the full training matrix was re-serialized with every K or seed task; now a task’s payload is the (k, seed) pair.

If you run NLPstudio inside your own outer parallelism (a SLURM array, future/furrr, mclapply), apply the same principle yourself: one parallel layer at a time. Bound the inner pools with quanteda::quanteda_options(threads = 1), data.table::setDTthreads(1), and RhpcBLASctl::blas_set_num_threads(1) inside each outer worker, or export the BLAS environment variables from your scheduler script.

Memory levers for topic models

Fitted nlp_topic_fit objects have a documented memory profile (see ?fit_topic_model, section Memory profile of a fit):

  • return_tww = FALSE skips caching the dense topics-by-vocabulary matrix - the dominant slot for n-gram vocabularies. Since 1.2.0 every consumer reconstructs it at most once per call, so lean fits evaluate at the same speed as cached ones and the flag is a pure memory saving.
  • seededlda fits no longer carry a full copy of the input dfm inside model_object$data (a zero-count ghost with identical dimnames and docvars replaces it; keep_backend_data = TRUE restores the old behavior).
  • evaluate_topic_model() processes likelihoods over the sparse non-zero entries in bounded blocks, and grid searches reuse one prepared coherence input across all K instead of rebuilding it per candidate.

Where NLPstudio ends and quanteda begins

NLPstudio does not re-implement text processing. The corpus verbs are deliberately thin: each one names the quanteda function it calls in its documentation, and adds input validation, logging, document-order guarantees, consistent data.table outputs, and the threads scoping described above. If you prefer calling quanteda directly for those steps, nothing is lost - the two compose freely.

The package’s own machinery sits before and after that layer:

  • Ingestion: SEC-style JSON to aligned corpus (from_json_to_df(), define_corpus()), plus curated financial dictionaries.
  • Type-level normalization quanteda does not provide: singularize_tokens() (rule-based English singularization), lemmatize_tokens() (spaCy-backed lemma mapping), and orchestration of spaCy parsing at scale (parse_corpus()).
  • One topic-modeling API over five engines (text2vec, topicmodels, seededlda, stm, topicmodels.etm) with standardized DTW/TWW matrices, cross-engine prediction, and adoption of pre-existing fits (as_nlp_topic_fit()).
  • Model evaluation and selection: engine-agnostic coherence, diversity, exclusivity, and likelihood metrics; stability diagnostics; grid search over K; and the bridge that hands a fitted VEM grid to OpTop’s optimal-topic test (as_optop_input()).

That last layer is where the 1.2.0 performance work matters most: a select_k_topics(..., return_fits = TRUE) grid is exactly what OpTop::optimal_topic() consumes, and grid fitting now scales to corpora where the old per-task serialization was prohibitive.