Skip to contents

Introduction

Almost all of the time that spacyr takes to parse texts is spent in spaCy’s pipeline: the sequence of components that the language model applies to each document after splitting it into tokens. The pipeline of spaCy’s small English model, en_core_web_sm, has six components:

  • tok2vec, which computes the vectors for each token that the next components use;
  • tagger, for the detailed part-of-speech tags;
  • parser, for the dependencies, which also sets the sentence boundaries and the noun phrases;
  • attribute_ruler, which maps the tags to universal part-of-speech tags;
  • lemmatizer, for the lemmas;
  • ner, the named entity recogniser.

Making spacyr faster mostly means running fewer of these components, running them on more documents at once, or running them in several processes. This article shows what each of these does, using the 60 US presidential inaugural addresses from quanteda, about 155,000 tokens, as texts:

library("spacyr")
library("quanteda")

txt <- as.character(data_corpus_inaugural)
sents <- as.character(corpus_reshape(data_corpus_inaugural, to = "sentences"))
length(txt)
## [1] 60
length(sents)
## [1] 5473

To time each call, we use a small function that runs it three times, and returns the median of the elapsed seconds:

seconds <- function(expr, times = 3) {
  expr <- substitute(expr)
  env <- parent.frame()
  round(median(replicate(times, system.time(eval(expr, env))[["elapsed"]])), 1)
}

The times below are from the machine described at the end of this article. They will be different on yours, and vary somewhat from one run to the next, so what matters is how they compare, not their values. Since the first calls after spacy_initialize() take longer, while spaCy finishes loading, we parse the texts once before timing anything:

spacy_initialize()
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
invisible(spacy_parse(txt, entity = FALSE))

Ask only for what you need, from the right function

The options of spacy_parse(), such as pos, lemma or dependency, choose the columns of its output. They do not turn components of the pipeline on or off: every component runs whatever the options are. So returning the dependencies costs little more than leaving them out, as reported in #188, and the same holds for turning several options off, or on, together:

data.frame(
  options = c("defaults",
              "adding dependency",
              "no pos, lemma or entity",
              "adding tag, dependency and nounphrase"),
  seconds = c(seconds(spacy_parse(txt)),
              seconds(spacy_parse(txt, dependency = TRUE)),
              seconds(spacy_parse(txt, pos = FALSE, lemma = FALSE, entity = FALSE)),
              seconds(spacy_parse(txt, tag = TRUE, dependency = TRUE, nounphrase = TRUE)))
)
##                                 options seconds
## 1                              defaults     2.6
## 2                     adding dependency     2.7
## 3               no pos, lemma or entity     2.6
## 4 adding tag, dependency and nounphrase     2.8

What extra time there is comes from building the extra columns, not from running the pipeline.

So it is better to ask for all the columns you will need in one call, than to call spacy_parse() once for each. entity_extract() and nounphrase_extract() take the entities and noun phrases from the output of spacy_parse(), without parsing the texts again:

parsed <- spacy_parse(txt[1:2], nounphrase = TRUE)
head(entity_extract(parsed), 3)
##            doc_id sentence_id                       entity entity_type
## 1 1789-Washington           1            Fellow_-_Citizens         ORG
## 2 1789-Washington           1                       Senate         ORG
## 3 1789-Washington           1 the_House_of_Representatives         ORG
head(nounphrase_extract(parsed), 3)
##            doc_id sentence_id      nounphrase
## 1 1789-Washington           1 Fellow-Citizens
## 2 1789-Washington           1      the_Senate
## 3 1789-Washington           1       the_House

The exception is spacy_tokenize(), which turns off every component, and only splits the texts into tokens:

seconds(spacy_tokenize(txt))
## [1] 0.2

If you need only the tokens, spacy_tokenize() is many times faster than spacy_parse(). Its options remove_punct, remove_numbers and remove_url depend only on the tokens, and keep it fast, but two of its options need the pipeline, and make it run all of the components: remove_symbols, which uses the part-of-speech tags, and what = "sentence", since the sentence boundaries come from the parser:

data.frame(
  call = c('spacy_tokenize(txt)',
           'spacy_tokenize(txt, remove_punct = TRUE)',
           'spacy_tokenize(txt, remove_symbols = TRUE)',
           'spacy_tokenize(txt, what = "sentence")'),
  seconds = c(seconds(spacy_tokenize(txt)),
              seconds(spacy_tokenize(txt, remove_punct = TRUE)),
              seconds(spacy_tokenize(txt, remove_symbols = TRUE)),
              seconds(spacy_tokenize(txt, what = "sentence")))
)
##                                         call seconds
## 1                        spacy_tokenize(txt)     0.2
## 2   spacy_tokenize(txt, remove_punct = TRUE)     0.2
## 3 spacy_tokenize(txt, remove_symbols = TRUE)     2.6
## 4     spacy_tokenize(txt, what = "sentence")     2.5

To remove symbols quickly, tokenize with spacy_tokenize(), and remove them afterwards, for instance with quanteda’s tokens(remove_symbols = TRUE), which removes tokens made of symbol characters instead of using the tags. The two can differ: quanteda removes + and ©, for instance, where spaCy may tag them otherwise and keep them.

Turn off the entity recogniser

The one component that spacyr can turn off is the named entity recogniser. spacy_initialize(entity = FALSE) loads the model with the recogniser disabled, so that it does not run when texts are parsed, which here saves about a quarter of the parsing time:

with_ner <- seconds(spacy_parse(sents, entity = FALSE))
spacy_finalize()

spacy_initialize(entity = FALSE)
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
invisible(spacy_parse(txt, entity = FALSE))
without_ner <- seconds(spacy_parse(sents, entity = FALSE))
spacy_finalize()

data.frame(ner = c("in the pipeline", "disabled"),
           seconds = c(with_ner, without_ner))
##               ner seconds
## 1 in the pipeline     2.7
## 2        disabled     2.0

spacy_parse() then has no entities to return, and says so if asked for them, unless entity rules are added with spacy_initialize(entity_patterns = ) (see the article on entity rules). The rules work without the recogniser, so long as they do not build on the entities it finds.

Choose the model

The models of a language differ mostly in their size and accuracy. Here, en_core_web_lg takes about as long as en_core_web_sm:

spacy_initialize(model = "en_core_web_lg")
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_lg)
invisible(spacy_parse(txt, entity = FALSE))
seconds(spacy_parse(txt))
## [1] 3
spacy_finalize()

since the two have the same components; most of what makes the large model large is its table of word vectors, which takes more memory and longer to load, but little more time to use. The transformer models, such as en_core_web_trf, are more accurate, but much slower, especially without a GPU.

Batch size

spaCy processes documents in batches, a number of documents at a time, and spacyr’s functions pass the batch_size argument to spaCy to set how many. The default, batch_size = NULL, uses the model’s own setting, which is 256 documents for en_core_web_sm. Very small batches are slower, but beyond some size, larger ones make little difference:

spacy_initialize()
## successfully initialized (spaCy Version: 3.8.18, language model: en_core_web_sm)
invisible(spacy_parse(txt, entity = FALSE))

sizes <- c(1, 10, 100, 1000)
data.frame(batch_size = sizes,
           seconds = sapply(sizes, function(b) seconds(spacy_parse(sents, batch_size = b))))
##   batch_size seconds
## 1          1     6.0
## 2         10     3.7
## 3        100     2.8
## 4       1000     2.8

The default is usually a good choice. A batch of long documents takes more memory than one of short documents, so with long documents, a smaller batch can avoid running out of memory.

Processes

The n_process argument starts that many Python processes, each with its own copy of the model, which share the documents between them; n_process = -1 uses all the CPUs. Starting the processes, loading the model in each, and sending the parsed documents back all take time, so they help only with many documents. For 5,473 sentences, more processes are slower:

procs <- c(1, 2, 4)
data.frame(n_process = procs,
           seconds = sapply(procs, function(p) seconds(spacy_parse(sents, n_process = p))))
##   n_process seconds
## 1         1     2.7
## 2         2     3.4
## 3         4     4.1

and for six times as many, timed once each, they make little difference:

more <- unname(rep(sents, 6))
data.frame(n_process = procs,
           seconds = sapply(procs, function(p) seconds(spacy_parse(more, n_process = p), times = 1)))
##   n_process seconds
## 1         1    16.4
## 2         2    15.1
## 3         4    14.9

So on this machine, with this model and these texts, there is little to gain. The gain may differ with other models, texts and machines, so time n_process on a sample of your own texts before relying on it. spacyr itself also adds some cost with more than one process, which #289 is about. Since spacy_tokenize() runs no components, it gains nothing from more processes, and only pays the costs of starting them and of sending the documents between them:

data.frame(n_process = c(1, 4),
           seconds = c(seconds(spacy_tokenize(more), times = 1),
                       seconds(spacy_tokenize(more, n_process = 4), times = 1)))
##   n_process seconds
## 1         1     0.8
## 2         4    11.4

A few things to keep in mind with more than one process:

  • Each process has its own copy of the model, so they need more memory. A process started by copying the R session’s Python process (“fork”) can share much of that memory with it at first.
  • How Python starts the processes depends on the system and the version of Python. On macOS and Windows, it starts each process afresh (“spawn”), which takes longer. On Linux, it copies the R session’s Python process (“fork”), or from Python 3.14 starts the processes from a separate server process (“forkserver”); see Python’s documentation on start methods.
  • A custom pipeline component must be defined in a Python module that the other processes can import.
  • With a pipeline that uses Hugging Face’s tokenizers library, as some transformer pipelines do, see the section below.

The output does not depend on n_process or batch_size: documents come back in their original order.

Long documents

spaCy accepts documents of up to 1,000,000 characters, unless spacy_initialize(max_length = ) sets another limit, and a document longer than the limit gives an error. spaCy’s rule of thumb is that the parser and the entity recogniser need roughly 1GB of temporary memory for each 100,000 characters of a document, so very long documents can use a great deal of memory. Splitting long documents into paragraphs or sentences first, for instance with quanteda’s corpus_reshape(), keeps each piece within the limit, uses less memory, and gives the batches and processes more documents to share. It has costs, however. Each piece becomes a document of its own, with its own name, so the results must be put back together by document if needed; and spaCy cannot see across the places where the documents were split, so sentence boundaries, dependencies and entities there can differ from those it would find in the whole document. Splitting at paragraphs keeps these differences fewest.

Transformer pipelines and TOKENIZERS_PARALLELISM

Some pipelines, such as those built with spacy-transformers, use Hugging Face’s tokenizers library. spacy_initialize() sets the environment variable TOKENIZERS_PARALLELISM to "false", unless it is already set, which stops older versions of that library from printing a warning again and again once the R process has been forked (#233). The setting turns off the library’s own threads for tokenizing, and how much that matters for the total time depends on the pipeline and the texts. Setting it to "true" before calling spacy_initialize() turns them back on, but also turns off the library’s safeguard against deadlocks: a process forked from the R session after the R session has used the library, such as a worker of parallel::mclapply(), or of n_process greater than 1 when the workers are started by fork, can then hang if it uses the library too. Leave it at "false" whenever that can happen.

Summary

  • Use spacy_tokenize() when you need only the tokens. Its remove_symbols option runs the whole pipeline; removing symbols afterwards, by their characters, is faster, if that definition of a symbol suits your task.
  • Ask spacy_parse() for all the columns you need in a single call; turning its options off saves little time.
  • Use spacy_initialize(entity = FALSE) when you do not need named entities.
  • Leave batch_size at its default, unless memory runs short.
  • Try n_process greater than 1 only for large numbers of documents, after timing it on a sample.
  • Split very long documents into smaller ones.

The times in this article were computed on this machine:

Sys.info()[["sysname"]]
## [1] "Darwin"
parallel::detectCores()
## [1] 10
R.version.string
## [1] "R version 4.6.1 (2026-06-24)"