Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions Sources/SwiftLM/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2380,13 +2380,16 @@ func handleChatCompletion(
prefillStepSize: config.prefillSize
)
generateParams.prefill.progress = forwardPrefillProgress
// ── Seed ──
// The samplers draw from their own RandomState, which only `GenerateParameters.seed`
// seeds; seeding the global generator alone left the request's `seed` without any
// effect (#219). The global generator is still seeded for the paths that call
// MLXRandom directly.
let seed = samplingSeed(chatReq.seed)
generateParams.seed = seed
if let seed { MLXRandom.seed(seed) }
let params = generateParams

// ── Seed for deterministic generation ──
if let seed = chatReq.seed {
MLXRandom.seed(UInt64(seed))
}

// ── Parse messages with multipart content support (for VLM images) ──
var chatMessages: [Chat.Message] = []
var systemPromptText = ""
Expand Down Expand Up @@ -3555,12 +3558,12 @@ func handleTextCompletion(
prefillStepSize: config.prefillSize
)
generateParams.prefill.progress = forwardPrefillProgress
// Same seeding as the chat path; see the comment there (#219).
let seed = samplingSeed(compReq.seed)
generateParams.seed = seed
if let seed { MLXRandom.seed(seed) }
let params = generateParams

if let seed = compReq.seed {
MLXRandom.seed(UInt64(seed))
}

await semaphore.wait()
let slot = GenerationSlot(semaphore: semaphore)
await stats.requestStarted()
Expand Down Expand Up @@ -4033,6 +4036,13 @@ func checkStopSequences(_ text: String, stopSequences: [String], lookback: Int?
return (String(text[text.startIndex..<earliest.index]), earliest.stop)
}

/// The sampler seed for a request `seed`. OpenAI's `seed` is a plain integer, so negative
/// values do arrive, and `UInt64(seed)` trapped on them — one such request took the whole
/// server down (#219). Taking the bit pattern keeps every distinct Int a distinct seed.
func samplingSeed(_ requested: Int?) -> UInt64? {
requested.map { UInt64(bitPattern: Int64($0)) }
}

/// Characters of already-checked text a streaming stop scan must look back over: the
/// longest stop string, plus one because a chunk can merge with the previous character
/// into a single grapheme.
Expand Down
49 changes: 49 additions & 0 deletions tests/SwiftLMTests/SamplingSeedTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import XCTest
import Foundation
import MLX
import MLXLMCommon
@testable import SwiftLM

// MARK: - The `seed` request field
//
// Two defects hid behind one line. `MLXRandom.seed(UInt64(seed))` seeded the *global*
// generator, but the samplers draw from their own `RandomState`, which is only seeded
// through `GenerateParameters.seed` — so the same seed produced different output on every
// call. And `UInt64(seed)` traps on a negative value, so `"seed": -1` took the whole
// server down with "Negative value is not representable".
final class SamplingSeedTests: XCTestCase {

func testAbsentSeedStaysAbsent() {
XCTAssertNil(samplingSeed(nil), "no seed means entropy-seeded sampling, as before")
}

func testNonNegativeSeedIsPassedThrough() {
XCTAssertEqual(samplingSeed(0), 0)
XCTAssertEqual(samplingSeed(42), 42)
XCTAssertEqual(samplingSeed(Int.max), UInt64(Int.max))
}

/// OpenAI's `seed` is a plain integer, so clients can and do send negative values.
/// They must map to *some* seed rather than crash the process.
func testNegativeSeedDoesNotTrap() {
XCTAssertEqual(samplingSeed(-1), UInt64(bitPattern: -1))
XCTAssertEqual(samplingSeed(Int.min), UInt64(bitPattern: Int64(Int.min)))
XCTAssertNotEqual(samplingSeed(-1), samplingSeed(-2), "distinct seeds stay distinct")
}

/// The seed must reach the sampler, not just the global generator: two samplers built
/// from the same parameters have to agree, and a different seed has to be able to
/// disagree. This is the property the server relies on for reproducible requests.
func testSeededParametersGiveReproducibleSampling() {
func sample(seed: UInt64?, draws: Int = 24) -> [Int] {
var params = GenerateParameters(temperature: 1.0, topP: 1.0)
params.seed = seed
let sampler = params.sampler()
// A flat distribution over a handful of tokens, so the draw is all RNG.
let logits = MLXArray([Float](repeating: 0, count: 64)).reshaped(1, 64)
return (0..<draws).map { _ in sampler.sample(logits: logits).item(Int.self) }
}
XCTAssertEqual(sample(seed: 42), sample(seed: 42), "same seed, same tokens")
XCTAssertNotEqual(sample(seed: 42), sample(seed: 43), "different seed, different tokens")
}
}
26 changes: 26 additions & 0 deletions tests/test-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,32 @@ else
fail "chunk-prefilled prompt returned no content: $(echo "$LONG_RESP" | head -c 120)"
fi

# ── 9. `seed` makes sampling reproducible, and a negative one is harmless ────
# Issue #219: the request seed only reached MLX's global generator, which the samplers
# don't draw from, so the same seed sampled differently every time; and UInt64(seed)
# trapped on a negative value, taking the whole server down with one request.
log "Test 9: the same seed samples the same answer"
SEEDED='{"model":"x","messages":[{"role":"user","content":"Pick a random animal and say only its name."}],"max_tokens":12,"temperature":1.0,"seed":4242}'
# The first call also fills the prompt cache; comparing two cache-hit calls keeps the
# assertion about the sampler rather than about prefill numerics.
chat "$SEEDED" >/dev/null
seeded_message() { chat "$SEEDED" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["choices"][0]["message"], sort_keys=True))'; }
S1=$(seeded_message); S2=$(seeded_message)
if [ -n "$S1" ] && [ "$S1" = "$S2" ]; then
pass "seeded sampling is reproducible ($(echo "$S1" | head -c 60))"
else
fail "same seed gave different output: $(echo "$S1" | head -c 60) vs $(echo "$S2" | head -c 60)"
fi

log "Test 9b: a negative seed is accepted and the server survives it"
NEG_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$URL/v1/chat/completions" -H 'Content-Type: application/json' \
-d '{"model":"x","messages":[{"role":"user","content":"hi"}],"max_tokens":3,"seed":-1}')
if [ "$NEG_CODE" = "200" ] && curl -sf "$URL/health" >/dev/null 2>&1; then
pass "negative seed answered 200 and /health still responds"
else
fail "negative seed: HTTP $NEG_CODE (000 = connection dropped), server alive: $(curl -sf "$URL/health" >/dev/null 2>&1 && echo yes || echo no)"
fi

log "═══════════════════════════════════════"
log "Results: $PASS passed, $FAIL failed, $SKIP skipped, $TOTAL total"
log "═══════════════════════════════════════"
Expand Down
Loading