
Performance and Threading
How NLPstudio, quanteda, BLAS, and data.table share your CPU
by Francesco Grossetti
Source:vignettes/performance-and-threading.Rmd
performance-and-threading.RmdNLPstudio 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 onlythreads = 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()andassess_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 = FALSEskips 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 = TRUErestores 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.