Skip to content

Commit c21fdcd

Browse files
shoemoneypcuenca
andauthored
Fix sampling returning a constant wrong token for vocabularies larger than 65,536 (#384)
* Fix multinomial sampling for vocabularies larger than 65,536 tokens The Bool mask pipeline in selectNextTokenUsingSampling (cumsum < rnd combined with arithmetic ops feeding argmin) truncates at 2^16 elements, so any model with a vocabulary above 65,536 tokens sampled a constant wrong token id on every step, and the same op sequence can crash in libBNNS (issue #365). Replace it with an inverse-CDF binary search on the CPU: read back the cumulative probabilities once per token and find the first index reaching the drawn value, which is correct for any vocabulary size. * Simplify comment --------- Co-authored-by: Pedro Cuenca <pedro@huggingface.co>
1 parent 0d78429 commit c21fdcd

3 files changed

Lines changed: 51 additions & 27 deletions

File tree

Sources/Generation/Decoders.swift

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,36 +20,44 @@ func selectNextTokenUsingGreedyDecoding(from scores: MLTensor) -> MLTensor {
2020
/// - Parameter scores: Processed logits tensor [batch_size, vocab_size]
2121
/// - Returns: Sampled token ID tensor [batch_size, 1]
2222
@available(macOS 15.0, iOS 18.0, tvOS 18.0, visionOS 2.0, watchOS 11.0, *)
23-
func selectNextTokenUsingSampling(from scores: MLTensor) -> MLTensor {
23+
func selectNextTokenUsingSampling(from scores: MLTensor) async -> MLTensor {
2424
// Convert logits to probabilities
2525
let probs = scores.softmax(alongAxis: -1)
2626

27-
// Multinomial sampling using cumulative sum method:
28-
// 1. Generate random number in [0, 1)
29-
// 2. Compute cumulative sum of probabilities
30-
// 3. Find first index where cumsum >= random_number
27+
// Multinomial sampling via inverse CDF, searched on the CPU.
3128
//
32-
// This is equivalent to torch.multinomial() but using available MLTensor ops
33-
34-
let batchSize = scores.shape[0]
35-
let rndTensor = MLTensor(randomUniform: [batchSize, 1], in: 0..<1, scalarType: Float.self)
29+
// CPU inverse-CDF search, replacing optimized tensor argmin path that breaks for vocabs > 2^16 (#365).
3630
let cumulativeProbs = probs.cumulativeSum(alongAxis: -1)
37-
38-
// Ensure random tensor matches the type of cumulativeProbs
39-
let rnd = cumulativeProbs.scalarType == Float.self ? rndTensor : rndTensor.cast(to: cumulativeProbs.scalarType)
40-
41-
// Create mask where cumsum >= rnd (these are candidates)
42-
// We want the FIRST position where this is true
43-
// Strategy: Set all positions where cumsum < rnd to a large value (1000.0)
44-
// Set all positions where cumsum >= rnd to their index value
45-
// Then argmin will give us the first qualifying index
46-
47-
let mask = cumulativeProbs .< rnd
48-
let penalized = mask * 1000.0 // Large value for positions to skip
49-
let indexed = penalized + cumulativeProbs // Positions >= rnd will have small values
50-
51-
let sampledIndex = indexed.argmin(alongAxis: -1).reshaped(to: [1, 1])
52-
// Ensure indices are Int32 for concatenation with input tokens
53-
return sampledIndex.scalarType == Int32.self ? sampledIndex : sampledIndex.cast(to: Int32.self)
31+
let floatCumulativeProbs = cumulativeProbs.scalarType == Float.self ? cumulativeProbs : cumulativeProbs.cast(to: Float.self)
32+
let cdf = await floatCumulativeProbs.shapedArray(of: Float.self).scalars
33+
34+
let vocabSize = scores.shape.last ?? 1
35+
let batchSize = max(cdf.count / vocabSize, 1)
36+
37+
var sampledTokens = [Int32]()
38+
sampledTokens.reserveCapacity(batchSize)
39+
for batch in 0..<batchSize {
40+
let row = cdf[(batch * vocabSize)..<((batch + 1) * vocabSize)]
41+
// Scale the draw by the total mass to be robust to floating-point
42+
// rounding in the last CDF entry.
43+
let rnd = Float.random(in: 0..<1) * (row.last ?? 1)
44+
45+
// Binary search for the first index whose cumulative probability
46+
// reaches the drawn value.
47+
var low = row.startIndex
48+
var high = row.endIndex - 1
49+
while low < high {
50+
let mid = (low + high) / 2
51+
if row[mid] < rnd {
52+
low = mid + 1
53+
} else {
54+
high = mid
55+
}
56+
}
57+
sampledTokens.append(Int32(low - row.startIndex))
58+
}
59+
60+
// Int32 indices, shaped [batch_size, 1] for concatenation with input tokens
61+
return MLTensor(shape: [batchSize, 1], scalars: sampledTokens)
5462
}
5563
#endif // canImport(CoreML)

Sources/Generation/Generation.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ extension Generation {
9090
case .greedy:
9191
selectNextTokenUsingGreedyDecoding(from: processedScores)
9292
case .sample:
93-
selectNextTokenUsingSampling(from: processedScores)
93+
await selectNextTokenUsingSampling(from: processedScores)
9494
default:
9595
fatalError("Generation mode \(config.generationMode) not implemented yet")
9696
}

Tests/GenerationTests/GenerationIntegrationTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,22 @@ final class GenerationIntegrationTests: XCTestCase {
340340
// Model should be called fewer times due to early stopping
341341
XCTAssertLessThan(model.callCount, 10, "Model should be called fewer times due to EOS")
342342
}
343+
344+
func testSamplingWithLargeVocabulary() async throws {
345+
// Regression test for #365: Bool-mask tensor ops truncate at 2^16 elements,
346+
// so sampling from a vocabulary larger than 65,536 tokens (e.g. Qwen's
347+
// 151,936) deterministically returned token 65536 regardless of the scores.
348+
let vocabSize = 151_936
349+
var logits = [Float](repeating: 0.0, count: vocabSize)
350+
logits[vocabSize - 1] = 100.0 // Effectively one-hot after softmax
351+
352+
let scores = MLTensor(shape: [1, 1, vocabSize], scalars: logits)
353+
let sampled = await selectNextTokenUsingSampling(from: scores)
354+
355+
XCTAssertEqual(sampled.shape, [1, 1], "Sampled token should have shape [1, 1]")
356+
let tokenId = await sampled.shapedArray(of: Int32.self).scalars.first
357+
XCTAssertEqual(tokenId, Int32(vocabSize - 1), "Sampling must select the only token with probability mass, never token 65536")
358+
}
343359
}
344360

345361
// MARK: - Test Helper

0 commit comments

Comments
 (0)