Speed up step_word_embeddings() - #309
Merged
Merged
Conversation
Rewrite tokenlist_embedding() to slice a numeric matrix and aggregate with rowsum() (sum/mean) or column-wise tapply() (min/max) instead of the dplyr group_by()/summarise_all() pipeline. ~13x faster for the default sum/mean aggregation and ~2.5x for min/max on a 5k-doc / 50k-vocab / 100-dim benchmark. See bench/ITERATIONS.md for the profiling-driven process.
Replace the slice + rowsum() reduction with a sparse-by-dense matrix product: a (n_doc x n_vocab) document-token incidence matrix times the dense embedding matrix yields per-document sums directly in document order, avoiding the large intermediate row-slice and grouped reduction (and the associated GC). The embedding matrix itself stays dense; only the incidence matrix is sparse, which it always is regardless of embedding values. ~19-22x faster than baseline for the default sum/mean aggregation.
Convert the embeddings tibble to a dense matrix (plus a token lookup vector) once in prep() instead of on every bake(). The prepped step now stores emb_matrix/emb_tokens in place of the embeddings tibble (no memory duplication), and tidy() reads the row count from emb_matrix. Removes the ~40MB per-bake allocation and associated GC, cutting sum/mean bake time roughly in half again (~31x faster than baseline).
Member
Author
|
The GHA failures are unrelated to this step |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
This PR is a showcase of using {debrief} to drive a profiling-based optimization. Each iteration below came from profiling with
profvis+debrief.Origin
This started from the following prompt:
Summary
Speeds up the
bake()path ofstep_word_embeddings(), which was driven bytokenlist_embedding()inR/tokenlist.R. The original implementation sliced the embedding tibble by row and ran a dplyrgroup_by()+summarise_all()pipeline per document. This was rewritten over several profiling-driven iterations into matrix/sparse-matrix operations.On a benchmark of 5,000 documents (10-30 tokens each), a 50,000-token vocabulary, and 100 embedding dimensions:
Output is identical to before for all four aggregations, including documents with no matched tokens falling back to
aggregation_default. Allword_embeddingstests pass.Iterations
The work was done as a sequence of profile-measure-change steps (full notes and benchmark script live in
bench/, which is.Rbuildignored).Baseline. Profiling showed nearly all the cost in
tokenlist_embedding(): slicing a 100-column tibble by row (vec_slice, ~140 MB allocated) and the dplyr group/summarise machinery, plus heavy GC.Iteration 1: matrix +
rowsum. Convert the embeddings to a numeric matrix and slice rows from it (matrix slicing is far cheaper than tibble slicing), pass the aggregation name through instead of an opaque closure, and aggregatesum/meanwith baserowsum(). → sum/mean ~13x; min/max ~2.2x (still using a per-groupapply()).Iteration 2: vectorise min/max. Replace the per-group
apply()with a loop over the ~100 embedding columns, each aggregated by a singletapply()(far fewer iterations). → modest further gain on min/max.Iteration 3: sparse incidence product for sum/mean. Re-profiling showed
rowsum.defaultand the row-slice as the new hot spots. Replaced them with a single sparse-by-dense matrix product: a (n_doc x n_vocab) document-token incidence matrix times the dense embedding matrix yields per-document sums directly, in document order, with no row-slice and no grouped reduction. The embedding matrix stays dense here; only the incidence matrix is sparse, and it is sparse by construction (each document holds only a few of the vocabulary's tokens) regardless of the embedding values.Matrixis already a dependency. → sum/mean ~19-22x.Iteration 4: precompute the dense matrix in
prep().as.matrix()was running on everybake(), allocating the whole ~40 MB embedding matrix each call and driving much of the GC. Moved the conversion intoprep(): the prepped step now stores a denseemb_matrixplus anemb_tokenslookup vector instead of the embeddings tibble (no memory duplication, since an all-numeric matrix is no larger than the tibble), andtidy()reads the row count fromemb_matrix. → sum/mean roughly halved again, ~31x overall.Rejected experiments
For min/max I also tried a single flattened
tapplyover all columns at once (~2x slower) and anorder()+duplicated(fromLast=)pick per column (~12% faster). The latter's gain was too small for the added complexity on a non-default path, so it was not kept.Notes
sum/meanpath is now down to tokenization plus the intrinsic matrix multiply; there is little structural overhead left to remove.devtools::test()run segfaults inside an unrelatedngramC-code test underload_all; this reproduces on a cleanmaintree (pre-existing, not caused by this PR). Theword_embeddingstests pass (40 PASS).