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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ disown
- `scripts/flash-mem-harness.sh` — manual harness that boots the real Flash 0731 model at various
context sizes and samples resident memory (NOT part of `swift test`; loads ~81 GB).
- `scripts/flash41-mem-harness.sh` — the V4.1 equivalent: `--ssd-streaming --power 100` on a
128 GiB+ Mac, cross-checks ds4's `ds4: memory:` plan against the `Feasibility` mirror (loads a
341 GiB GGUF; Engram rows stay on disk).
96 GiB+ Mac (set `DS41_LIMIT_GIB` to the machine's RAM; default 128), cross-checks ds4's
`ds4: memory:` plan against the `Feasibility` mirror (loads a 341 GiB GGUF; Engram rows stay
on disk).
- CI: `.github/workflows/ci.yml` (build + test, bundles ds4), `release.yml` (tag-triggered
Developer ID signed + notarized release).

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ DeepSeek V4 is memory-hungry so DS4 Control gates feasibility before launching.
| Variant | Quant | RAM | Notes |
| --- | --- | --- | --- |
| V4 Pro (0813) | pro-imatrix | **≥ 512 GiB required** | Anything below is blocked. |
| V4.1 Flash | 41-q2 | ≥ 128 GiB | 341 GiB on disk, ~152 GiB resident main weights + ~189 GiB disk-only Engram. SSD streaming engages automatically on 128–255 GiB; full residency on ≥ 256 GiB. |
| V4.1 Flash | 41-q2 | ≥ 96 GiB | 341 GiB on disk, ~152 GiB resident main weights + ~189 GiB disk-only Engram. SSD streaming engages automatically on 96–255 GiB (slow: nearly every routed expert streams from disk); full residency on ≥ 256 GiB. |
| V4.1 Flash | 41-q4 | ≥ 256 GiB | 483 GiB on disk, ~294 GiB resident main weights. SSD streaming on 256–511 GiB; full residency on ≥ 512 GiB. |
| V4 Flash (0731) | q4-imatrix | ≥ 256 GiB | Standard. |
| V4 Flash (0731) | q2-imatrix | 96 GiB minimum | 96–127 GiB requires raising the Metal wired limit (see below). |
Expand Down
5 changes: 3 additions & 2 deletions Sources/DS4Control/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ final class AppState: ObservableObject {
}
}

/// Restores persisted preferences and chooses memory-appropriate defaults for a fresh install.
init(defaults: UserDefaults = .standard, ramGiB: Double = systemRamGiB()) {
self.d = defaults
port = d.object(forKey: "port") as? Int ?? 8000
Expand Down Expand Up @@ -109,9 +110,9 @@ final class AppState: ObservableObject {
}
legacyStoragePromptDismissed = d.bool(forKey: "legacyStoragePromptDismissed0813")
let stored = d.string(forKey: "selectedVariant").flatMap(Variant.init(rawValue:))
// Default Pro on ≥512 GiB, V4.1 Flash on ≥128 GiB, V4 Flash (0731) below that.
// Default Pro on ≥512 GiB, V4.1 Flash on ≥96 GiB, V4 Flash (0731) below that.
// Stored selections are never auto-migrated: an existing 0731 user keeps 0731.
selectedVariant = stored ?? (ramGiB >= 512 ? .pro : ramGiB >= 128 ? .flash41 : .flash)
selectedVariant = stored ?? (ramGiB >= 512 ? .pro : ramGiB >= 96 ? .flash41 : .flash)
let storedQuant = d.string(forKey: "selectedFlashQuant").flatMap(FlashQuant.init(rawValue:))
selectedFlashQuant = storedQuant ?? defaultFlashQuant(ramGiB: ramGiB) // default q2-q4-imatrix
let storedQuant41 = d.string(forKey: "selectedFlash41Quant").flatMap(Flash41Quant.init(rawValue:))
Expand Down
12 changes: 7 additions & 5 deletions Sources/DS4Control/Model/Feasibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -563,10 +563,12 @@ func flash41UsesSSDStreaming(
return resident > flash41BudgetMB(ramGiB: ramGiB, wiredLimitMB: wiredLimitMB)
}

/// Documented RAM floor per V4.1 quant (README tier table): 41-q2 runs from 128 GiB via SSD
/// streaming; 41-q4 requires ≥ 256 GiB — the 128 GiB class would have to stream essentially
/// every routed expert, which the supported tiers do not offer.
func flash41MinRamGiB(_ q: Flash41Quant) -> Double { q == .q4 ? 256 : 128 }
/// Documented RAM floor per V4.1 quant (README tier table): 41-q2 runs from 96 GiB via SSD
/// streaming — verified against the pinned ds4 admission with the real Q2 weights, which
/// admits a 96 GiB host (3,638 cached experts at a 72 GiB Metal recommended set and 1M ctx)
/// — and 41-q4 requires ≥ 256 GiB: below that it would have to stream essentially every
/// routed expert, which the supported tiers do not offer.
func flash41MinRamGiB(_ q: Flash41Quant) -> Double { q == .q4 ? 256 : 96 }

/// Whether a V4.1 quant's default launch fits this machine. Drives which options the
/// Settings quant picker offers; the RAM floor is the documented tier, then the launch's
Expand Down Expand Up @@ -717,7 +719,7 @@ func feasibility(
return .blocked(
reason: q == .q4
? "V4.1 Flash 41-q4 needs ≥ 256 GiB unified memory. Its 294 GiB of main weights would stream essentially every routed expert below that tier, which is not supported."
: "V4.1 Flash needs ≥ 128 GiB unified memory. Its 152 GiB of resident main weights, plus disk-only Engram streaming, cannot run safely below that."
: "V4.1 Flash needs ≥ 96 GiB unified memory. Below that, even the SSD-streaming fixed set (non-routed weights, graph, and prefill headroom) plus a routed expert cannot be admitted."
)
}
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/DS4Control/Views/ModelRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ struct ModelRowView: View {
@Environment(\.openWindow) private var openWindow
let ramGiB: Double

/// Model variants offered by the popup for the machine's unified-memory tier.
private var variants: [Variant] {
if ramGiB >= 512 { return [.pro, .flash, .flash41] }
if ramGiB >= 128 { return [.flash, .flash41] }
if ramGiB >= 96 { return [.flash, .flash41] }
return [.flash]
}

Expand Down
17 changes: 13 additions & 4 deletions Tests/DS4ControlTests/AppStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ final class AppStateTests: XCTestCase {
XCTAssertEqual(AppState(defaults: off, ramGiB: 96).thinkingMode, .off)
}

/// Max Think is downgraded safely while each 96 GiB model keeps its own default context.
func testMaxThinkingModeDowngradesBelow128GiB() {
let defaults = UserDefaults(suiteName: "test.\(UUID().uuidString)")!
defaults.set(ThinkingMode.max.rawValue, forKey: "thinkingMode")
Expand All @@ -131,6 +132,10 @@ final class AppStateTests: XCTestCase {

XCTAssertEqual(app.thinkingMode, .standard)
XCTAssertEqual(app.ctxOverride, 0)
// 96 GiB now defaults to V4.1 Flash (streaming → 32,768); the 0731 tier still
// defaults to 256,000 there.
XCTAssertEqual(app.effectiveCtx(ramGiB: 96), 32_768)
app.selectedVariant = .flash
XCTAssertEqual(app.effectiveCtx(ramGiB: 96), 256_000)
XCTAssertEqual(defaults.string(forKey: "thinkingMode"), ThinkingMode.standard.rawValue)
XCTAssertEqual(defaults.integer(forKey: "ctxOverride"), 0)
Expand Down Expand Up @@ -169,13 +174,17 @@ final class AppStateTests: XCTestCase {
XCTAssertEqual(app2.thinkingMode, .max)
}

func testFreshInstallDefaultsFlash41On128GiB() {
/// Fresh installs select the expected model at each supported unified-memory tier.
func testFreshInstallDefaultsFlash41From96GiB() {
let a96 = AppState(
defaults: UserDefaults(suiteName: "test.\(UUID().uuidString)")!, ramGiB: 96)
XCTAssertEqual(a96.selectedVariant, .flash41)
let a128 = AppState(
defaults: UserDefaults(suiteName: "test.\(UUID().uuidString)")!, ramGiB: 128)
XCTAssertEqual(a128.selectedVariant, .flash41)
let a96 = AppState(
defaults: UserDefaults(suiteName: "test.\(UUID().uuidString)")!, ramGiB: 96)
XCTAssertEqual(a96.selectedVariant, .flash)
let a64 = AppState(
defaults: UserDefaults(suiteName: "test.\(UUID().uuidString)")!, ramGiB: 64)
XCTAssertEqual(a64.selectedVariant, .flash)
let a512 = AppState(
defaults: UserDefaults(suiteName: "test.\(UUID().uuidString)")!, ramGiB: 512)
XCTAssertEqual(a512.selectedVariant, .pro)
Expand Down
50 changes: 45 additions & 5 deletions Tests/DS4ControlTests/FeasibilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,13 +363,20 @@ final class FeasibilityTests: XCTestCase {
ramGiB: 512, wiredLimitMB: 393_216, quant: .q41Q4, ctx: 32_768, sessions: 1))
}

/// V4.1 quant tiers admit supported memory sizes and block hosts below their RAM floors.
func testFlash41FloorsAndGates() {
if case .standard = feasibility(
ramGiB: 96, selection: .flash41(.q2), ctx: 32_768, wiredLimitMB: 73_728)
{
} else {
XCTFail("V4.1 q2 on 96 GiB with streaming must pass (verified against ds4 admission)")
}
if case let .blocked(reason) = feasibility(
ramGiB: 96, selection: .flash41(.q2), ctx: 32_768, wiredLimitMB: 86_016)
ramGiB: 64, selection: .flash41(.q2), ctx: 32_768, wiredLimitMB: 61_440)
{
XCTAssertTrue(reason.contains("128 GiB"))
XCTAssertTrue(reason.contains("96 GiB"))
} else {
XCTFail("V4.1 below 128 GiB must block")
XCTFail("V4.1 below 96 GiB must block")
}
if case .standard = feasibility(
ramGiB: 128, selection: .flash41(.q2), ctx: 32_768, wiredLimitMB: 98_304)
Expand All @@ -392,8 +399,39 @@ final class FeasibilityTests: XCTestCase {
}
}

/// The pinned ds4 admission at the app's lowest V4.1 tier: on a 96 GiB host with the
/// macOS-default ~72 GiB Metal set, the streamed fixed set leaves room for a routed
/// expert, so the launch is admitted (verified directly against `--memory-plan` with the
/// real Q2 weights: 4,827 cached experts at 32K ctx, 3,638 at 1M).
func testFlash41Q2AdmittedOn96GiBViaStreaming() {
let wired = 72 * 1024
XCTAssertTrue(
flash41UsesSSDStreaming(
ramGiB: 96, wiredLimitMB: wired, quant: .q41Q2, ctx: 32_768, sessions: 1))
XCTAssertEqual(
feasibility(
ramGiB: 96, selection: .flash41(.q2), ctx: 32_768, wiredLimitMB: wired),
.standard)
XCTAssertEqual(
feasibility(
ramGiB: 96, selection: .flash41(.q2), ctx: 1_048_576, wiredLimitMB: wired),
.standard)
// The streamed fixed set leaves ~46 GiB under the Metal set — far more than one
// routed layer's experts — so ds4's admission (mirrored here) holds.
let fixed = v41FixedWiredMB(quant: .q41Q2, ctx: 32_768, sessions: 1, streaming: true)
XCTAssertEqual(fixed, 27_007)
XCTAssertEqual(flash41BudgetMB(ramGiB: 96, wiredLimitMB: wired), wired)
XCTAssertGreaterThan(wired - fixed, ds41Q2PrefillHeadroomBytes / 1_048_576 / 2)
// Parallel slots: the 32K graph fits six sessions under the default Metal set.
XCTAssertEqual(
maxFittingSessions(
ramGiB: 96, selection: .flash41(.q2), ctx: 32_768, wiredLimitMB: wired), 6)
}

/// Quant availability follows the 96 GiB Q2 and 256 GiB Q4 minimum-memory tiers.
func testFlash41QuantFitFloors() {
XCTAssertFalse(flash41QuantFits(.q2, ramGiB: 96, wiredLimitMB: Int.max))
XCTAssertTrue(flash41QuantFits(.q2, ramGiB: 96, wiredLimitMB: 73_728))
XCTAssertFalse(flash41QuantFits(.q2, ramGiB: 64, wiredLimitMB: Int.max))
XCTAssertFalse(flash41QuantFits(.q4, ramGiB: 96, wiredLimitMB: Int.max))
XCTAssertTrue(flash41QuantFits(.q2, ramGiB: 128, wiredLimitMB: 98_304))
// 41-q4's documented floor is 256 GiB (README tier table): the 128 GiB class streams
Expand All @@ -403,9 +441,11 @@ final class FeasibilityTests: XCTestCase {
XCTAssertTrue(flash41QuantFits(.q4, ramGiB: 256, wiredLimitMB: 196_608))
}

/// V4.1 defaults use streaming context below full-residency tiers and the expected quant.
func testFlash41DefaultCtxAndQuant() {
// Streaming tiers keep upstream's documented 32,768 default: 41-q2 on 128 GiB, and
// Streaming tiers keep upstream's documented 32,768 default: 41-q2 from 96 GiB, and
// 41-q4 through its 256 GiB floor (the 1M window doesn't fit fully resident yet).
XCTAssertEqual(defaultCtx(ramGiB: 96, selection: .flash41(.q2)), 32_768)
XCTAssertEqual(defaultCtx(ramGiB: 128, selection: .flash41(.q2)), 32_768)
XCTAssertEqual(defaultCtx(ramGiB: 256, selection: .flash41(.q4)), 32_768)
// Tiers that hold the full 1M window resident default to the ceiling instead.
Expand Down
3 changes: 2 additions & 1 deletion Tests/DS4ControlTests/GUIHostOptionSourceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,12 @@ final class GUIHostOptionSourceTests: XCTestCase {
XCTAssertLessThan(partRemoval.lowerBound, finalRemoval.lowerBound)
}

/// The popup offers V4.1 Flash beginning at the supported 96 GiB memory tier.
func testModelRowOffersFlash41ByRAMTier() throws {
let modelRow = try source("Sources/DS4Control/Views/ModelRowView.swift")

XCTAssertTrue(modelRow.contains("if ramGiB >= 512 { return [.pro, .flash, .flash41] }"))
XCTAssertTrue(modelRow.contains("if ramGiB >= 128 { return [.flash, .flash41] }"))
XCTAssertTrue(modelRow.contains("if ramGiB >= 96 { return [.flash, .flash41] }"))
XCTAssertTrue(modelRow.contains("return [.flash]"))
}
}
3 changes: 2 additions & 1 deletion scripts/flash41-mem-harness.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/bin/sh
# flash41-mem-harness.sh — measure V4.1 Flash (Q2) resident memory across context sizes on a
# 128 GiB+ Mac. Spins up the REAL ds4-server with --ssd-streaming, warms the model, prefills to
# 96 GiB+ Mac (96 GiB is the app's lowest SSD-streaming tier for 41-q2). Spins up the REAL
# ds4-server with --ssd-streaming, warms the model, prefills to
# the configured context frontier, and records peak RSS plus ds4's own memory plan.
#
# The GGUF carries ~189 GiB of disk-only Engram tables that never become resident; only the
Expand Down