Speed up ModernBERT inference by 1.15x on CUDA - #897
Conversation
Benchmark detailsThis comment contains the benchmark methodology and full measurement details Headline result ( Environment and measurement procedureEnvironment
GPU 1 was selected explicitly with Workload and procedure
The baseline is the exact repository Throughput
The optimized implementation is 1.15x faster than Output comparisonThe optimized responses had shape
The outputs are not bit-identical because the cuBLASLt GELU epilogue changes Reproduction commandsReproductionDownload the model once and build the CUDA binary with the target architecture: hf download Alibaba-NLP/gte-modernbert-base --local-dir /tmp/gte-modernbert-base
CUDA_COMPUTE_CAP=120 cargo build --release --bin text-embeddings-router -F candle-cudaStart the server on the selected GPU: CUDA_VISIBLE_DEVICES=1 \
target/release/text-embeddings-router \
--model-id /tmp/gte-modernbert-base \
--dtype float16 \
--pooling cls \
--port 18080 \
--prometheus-port 19090 \
--max-batch-tokens 16384 \
--max-client-batch-size 1024 \
--max-concurrent-requests 16384 \
--disable-spansRun the client with the same options used for the measurement: python benchmark_gte_modernbert_nq.py \
--url http://127.0.0.1:18080/embed \
--split 'train[:20000]' \
--batch-size 1024 \
--workers 16 \
--warmup-batches 4 \
--label optimized \
--save-embeddings /tmp/optimized-embeddings.npyThe Python process is only the HTTP benchmark client; model inference runs in For the baseline, run the same server and client commands from the Additional model result ( Additional model validation detailsAdditional model validation:
|
| Revision | Run 1 (texts/s) | Run 2 (texts/s) | Run 3 (texts/s) | Median (texts/s) | Median elapsed |
|---|---|---|---|---|---|
main (fc071b1) |
1,192.3 | 1,176.9 | 1,169.0 | 1,176.9 | 16.993 s |
| Optimized | 1,382.8 | 1,379.2 | 1,372.4 | 1,379.2 | 14.501 s |
The optimized implementation is 1.17x faster on this model (+17.18%
median throughput). The output shape was (20000, 1024).
| Check | Value |
|---|---|
| Minimum cosine similarity | 0.995162606 |
| 1st percentile cosine similarity | 0.999869466 |
| Median cosine similarity | 0.999999225 |
| Mean cosine similarity | 0.999998271 |
| Samples below 0.999 cosine similarity | 2 / 20,000 |
| Samples below 0.99 cosine similarity | 0 / 20,000 |
| Maximum absolute difference | 0.016730726 |
| Mean absolute difference | 0.000033006 |
| Maximum norm error from 1 | 1.19e-7 |
As with gte-modernbert-base, the outputs are not bit-identical because of
the cuBLASLt GELU epilogue's floating-point operation order, but the outputs
remain normalized and effectively equivalent for this validation.
alvarobartt
left a comment
There was a problem hiding this comment.
Awesome thanks a lot @hotchpotch! Before we merge, do you mind moving the tests into backends/candle/tests/test_modernbert.rs?
There was a problem hiding this comment.
Pull request overview
This PR optimizes ModernBERT’s CUDA GELU MLP path by splitting the combined Wi projection into two linear layers so the CUDA linear implementation can use cuBLASLt’s fused GELU epilogue, improving inference throughput while keeping non-CUDA and non-GELU behavior unchanged.
Changes:
- Split
Wiinto an activated GELU projection (wi) plus a separate gate projection (gate) on CUDA+GELU, then multiply and applyWo. - Preserve the original unsplit projection + separate activation flow for CPU/Metal and non-GELU activations.
- Add unit tests to ensure CPU keeps the unsplit path and to validate split vs. unsplit MLP equivalence.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fn should_split_wi(device: &Device, activation: &HiddenAct) -> bool { | ||
| matches!(device, Device::Cuda(_)) && matches!(activation, HiddenAct::Gelu) | ||
| } |
There was a problem hiding this comment.
Good catch. Addressed in 5f76b6c by requiring
get_cublas_lt_wrapper().is_some() in should_split_wi, so the split is only
selected when the fused GELU path is available.
I also rebuilt with CUDA and reran the controlled GTE ModernBERT benchmark:
the reviewed build remained 1.1508x (+15.08%) faster than main by median
throughput, with the same embedding-comparison metrics as before.
|
Thanks, @alvarobartt! I moved the integration-friendly MLP equivalence coverage to One note: the source-local test module also contained two tests that directly
These tests could not be moved verbatim because integration tests are compiled If retaining that private implementation coverage is important, I can either |
|
Oh ACK @hotchpotch then feel free to add those back for ModernBERT and we'll then merge, apologies for the confusion, and thanks again for your contribution 🤗 |
|
@alvarobartt |
What does this PR do?
This PR speeds up CUDA ModernBERT inference by splitting the GELU MLP input
projection so that the CUDA linear path can use cuBLASLt's fused GELU epilogue.
Method
ModernBERT's GELU MLP computes the following operation:
The checkpoint stores both projections in one
Wimatrix. For CUDA deviceswith GELU activation, this change loads the two halves as separate linear
layers:
CUDA implementation to fuse GELU into the cuBLASLt epilogue.
Woprojection.The transformation preserves the model computation while improving the
matmul/activation execution for the CUDA workload measured here.
Compatibility and scope
projection and activation flow.
are unchanged.
backends/candle/src/models/modernbert.rs; no new dependency is required.Validation
cargo test -p text-embeddings-backend-candle --lib modernbertcomputations.
Alibaba-NLP/gte-modernbert-basebenchmark using 20,000 Natural Questions examples improved throughput by
1.15x over
main(fc071b1cb6e1b091b67f20868de7c5982aa7d4d0).lightonai/modernbert-embed-largewith mean poolingimproved throughput by 1.17x over the same
mainbaseline.Detailed benchmark methodology, environment, results, and numerical comparison
are provided in the first PR comment to keep this description focused.
Before submitting
Who can review?
@Narsil @alvarobartt