Skip to content

Commit 64b68de

Browse files
NorbertKlockiewiczclaude
authored andcommitted
perf: speed up top-p sampling for large vocabularies (#1232)
## Description Optimizes token sampling for large-vocabulary models (e.g. Gemma 4 E2B, 262k vocab), where the previous full-vocabulary sort in top-p dominated per-token latency. Two changes in `sampler.cpp`: - **`mask_topp`**: replaces the `O(n log n)` sort over all logits with a logit-space histogram (`kBins=2048`) that locates the nucleus threshold in two `O(n)` passes — no sort, no per-token vocab-sized allocation. Binning in logit space (rather than probability space) keeps uniform resolution for both peaked and flat distributions. - **`softmax`**: skips `exp()` on logits already masked to `lowest()` by top-k/top-p. The result underflows to zero anyway, and the call is slow on device. On an iPhone 17 Pro with Gemma 4 E2B (int4), per-token sampling drops from ~45 ms to ~10 ms. The histogram approximates the exact sort-based nucleus; the resulting sampled distribution is statistically equivalent (verified the kept-mass fraction stays within <1% of the exact nucleus across peaked, flat, and sharp distributions). ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [ ] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [x] Other (chores, tests, code style improvements etc.) ### Tested on - [x] iOS - [ ] Android ### Testing instructions 1. Run an LLM with a large vocabulary and a non-zero temperature with `topP` set (e.g. Gemma 4 E2B with `temperature: 0.8`, `topP: 0.9`). 2. Generate a long response and observe tokens/sec. 3. Confirm output remains coherent and sampling is unchanged in character (still stochastic, not greedy). Greedy decoding (`temperature: 0`) is unaffected — it bypasses this path entirely. ### Screenshots <!-- N/A --> ### Related issues <!-- N/A --> ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes The histogram is an approximation bounded by bin granularity (`kBins=2048` over a `kRange=40` logit span). This is intentional: exact top-p over a 262k vocab where the nucleus can exceed 100k tokens is inherently expensive, and the sampling outcome is statistically indistinguishable from the exact version. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aea5db0 commit 64b68de

1 file changed

Lines changed: 52 additions & 41 deletions

File tree

  • packages/react-native-executorch/common/runner

‎packages/react-native-executorch/common/runner/sampler.cpp‎

Lines changed: 52 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
#include <algorithm>
3737
#include <ctime>
3838
#include <limits>
39+
#include <ranges>
40+
#include <span>
41+
#include <type_traits>
3942
#include <vector>
4043

4144
namespace executorch {
@@ -141,57 +144,59 @@ template <typename T> void Sampler::mask_topk(T *logits) {
141144
}
142145
}
143146

144-
// Mask logits whose softmax-prob falls outside the top-p nucleus to -inf.
145-
// Keeps the token that crosses the threshold (HuggingFace convention).
147+
// Mask logits outside the top-p nucleus to -inf. Approximates the exact
148+
// sort-based nucleus with a histogram over (logit - max): two O(n) passes, no
149+
// sort. Binning in logit (not probability) space keeps uniform resolution for
150+
// peaked and flat distributions alike. kRange=40 spans exp() down to ~4e-18.
146151
template <typename T> void Sampler::mask_topp(T *logits) {
147152
if (topp_ <= 0.0f || topp_ >= 1.0f) {
148153
return;
149154
}
150-
// Softmax into a scratch probs[] (do not mutate logits yet).
151-
T max_val = logits[0];
152-
for (size_t i = 1; i < vocab_size_; i++) {
153-
if (logits[i] > max_val) {
154-
max_val = logits[i];
155-
}
156-
}
157-
std::unique_ptr<ProbIndex<T>[]> probindex =
158-
std::make_unique<ProbIndex<T>[]>(vocab_size_);
159-
T sum = 0;
155+
constexpr int32_t kBins = 2048;
156+
// Compute in a type at least as wide as T so converting logits never loses
157+
// precision: double stays double, everything else (float and the narrow
158+
// half/bf16/uint16 logit types) widens to float. Accumulating in T directly
159+
// would be unsafe for bf16, whose mantissa saturates when summing exp()
160+
// over the full vocab.
161+
using acc_t = std::conditional_t<std::is_same_v<T, double>, double, float>;
162+
constexpr acc_t kRange = 40;
163+
164+
std::span<const T> logit_span{logits, static_cast<size_t>(vocab_size_)};
165+
const acc_t max_val =
166+
static_cast<acc_t>(*std::ranges::max_element(logit_span));
167+
168+
std::vector<acc_t> bin_mass(kBins, acc_t(0));
169+
acc_t total = 0;
160170
for (size_t i = 0; i < vocab_size_; i++) {
161-
T e = static_cast<T>(std::expf(static_cast<float>(logits[i] - max_val)));
162-
probindex[i].prob = e;
163-
probindex[i].index = i;
164-
sum += e;
171+
acc_t d = static_cast<acc_t>(logits[i]) - max_val;
172+
acc_t e = std::exp(d);
173+
total += e;
174+
int32_t bin = static_cast<int32_t>((d + kRange) / kRange * kBins);
175+
bin = std::clamp(bin, 0, kBins - 1);
176+
bin_mass[bin] += e;
165177
}
166-
if (sum <= T(0)) {
178+
if (total <= acc_t(0)) {
167179
return;
168180
}
169-
for (size_t i = 0; i < vocab_size_; i++) {
170-
probindex[i].prob /= sum;
171-
}
172-
std::sort(probindex.get(), probindex.get() + vocab_size_,
173-
[](const ProbIndex<T> &a, const ProbIndex<T> &b) {
174-
return a.prob > b.prob;
175-
});
176181

177-
// Find the smallest prefix whose cumulative probability >= topp_.
178-
T cumulative = 0;
179-
int last_idx = vocab_size_ - 1;
180-
for (size_t i = 0; i < vocab_size_; i++) {
181-
cumulative += probindex[i].prob;
182-
if (static_cast<float>(cumulative) >= topp_) {
183-
last_idx = i;
182+
// Highest bin downward until the kept mass reaches topp. The crossing bin is
183+
// kept (HuggingFace "keep the token that crosses" convention).
184+
const acc_t target = static_cast<acc_t>(topp_) * total;
185+
acc_t acc = 0;
186+
int32_t keep_bin = 0;
187+
for (int32_t bin = kBins - 1; bin >= 0; --bin) {
188+
acc += bin_mass[bin];
189+
if (acc >= target) {
190+
keep_bin = bin;
184191
break;
185192
}
186193
}
187-
// Mark kept indices, then -inf the rest.
188-
std::vector<bool> keep(vocab_size_, false);
189-
for (size_t i = 0; i <= last_idx; i++) {
190-
keep[probindex[i].index] = true;
191-
}
194+
const acc_t d_threshold =
195+
static_cast<acc_t>(keep_bin) / kBins * kRange - kRange;
196+
192197
constexpr T neg_inf = std::numeric_limits<T>::lowest();
193198
for (size_t i = 0; i < vocab_size_; i++) {
194-
if (!keep[i]) {
199+
if (static_cast<acc_t>(logits[i]) - max_val < d_threshold) {
195200
logits[i] = neg_inf;
196201
}
197202
}
@@ -210,22 +215,28 @@ Sampler::Sampler(int32_t vocab_size, GenerationConfig config)
210215
: Sampler(vocab_size, config, std::time(nullptr)) {}
211216

212217
template <typename T> static void softmax(T *x, int size) {
213-
// find max value (for numerical stability)
218+
// Runs after top-k/top-p masking, which sets rejected logits to lowest().
219+
// Skip exp() on those: it underflows to 0 anyway and is slow on device.
220+
constexpr T kMasked = std::numeric_limits<T>::lowest();
214221
T max_val = x[0];
215222
for (size_t i = 1; i < size; i++) {
216223
if (x[i] > max_val) {
217224
max_val = x[i];
218225
}
219226
}
220-
// exp and sum
221227
T sum = 0;
222228
for (size_t i = 0; i < size; i++) {
229+
if (x[i] == kMasked) {
230+
x[i] = T(0);
231+
continue;
232+
}
223233
x[i] = expf(x[i] - max_val);
224234
sum += x[i];
225235
}
226-
// normalize
227236
for (size_t i = 0; i < size; i++) {
228-
x[i] /= sum;
237+
if (x[i] != T(0)) {
238+
x[i] /= sum;
239+
}
229240
}
230241
}
231242

0 commit comments

Comments
 (0)