lite node
This commit is contained in:
@@ -21,6 +21,7 @@ final class ClusterService {
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private var pollingTask: Task<Void, Never>?
|
||||
private var heartbeatTask: Task<Void, Never>?
|
||||
|
||||
private static let connectionInfoKey = "exo_last_connection_info"
|
||||
|
||||
@@ -62,6 +63,7 @@ final class ClusterService {
|
||||
connectionState = .connected(info)
|
||||
persistConnection(info)
|
||||
startPolling()
|
||||
startHeartbeat()
|
||||
await fetchModels(baseURL: info.baseURL)
|
||||
} catch {
|
||||
connectionState = .disconnected
|
||||
@@ -87,6 +89,7 @@ final class ClusterService {
|
||||
|
||||
func disconnect() {
|
||||
stopPolling()
|
||||
stopHeartbeat()
|
||||
connectionState = .disconnected
|
||||
availableModels = []
|
||||
lastError = nil
|
||||
@@ -118,6 +121,49 @@ final class ClusterService {
|
||||
pollingTask = nil
|
||||
}
|
||||
|
||||
// MARK: - Heartbeat
|
||||
|
||||
private func startHeartbeat(interval: TimeInterval = 10.0) {
|
||||
stopHeartbeat()
|
||||
heartbeatTask = Task { [weak self] in
|
||||
// Send immediately, then on interval
|
||||
if let self, let connection = self.currentConnection {
|
||||
await self.sendHeartbeat(baseURL: connection.baseURL)
|
||||
}
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(interval))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
guard let connection = self.currentConnection else { return }
|
||||
await self.sendHeartbeat(baseURL: connection.baseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopHeartbeat() {
|
||||
heartbeatTask?.cancel()
|
||||
heartbeatTask = nil
|
||||
}
|
||||
|
||||
private func sendHeartbeat(baseURL: URL) async {
|
||||
do {
|
||||
let deviceInfo = DeviceInfoService.gather()
|
||||
let url = baseURL.appendingPathComponent("v1/lite_node/heartbeat")
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 5
|
||||
request.httpBody = try JSONEncoder().encode(deviceInfo)
|
||||
let (_, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200..<300).contains(httpResponse.statusCode) {
|
||||
// Heartbeat failed silently — will retry on next interval
|
||||
}
|
||||
} catch {
|
||||
// Heartbeat failed silently — will retry on next interval
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
private func fetchModels(baseURL: URL) async {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import os
|
||||
import UIKit
|
||||
|
||||
struct DeviceInfo: Encodable {
|
||||
let nodeId: String
|
||||
let model: String
|
||||
let chip: String
|
||||
let osVersion: String
|
||||
let friendlyName: String
|
||||
let ramTotal: Int
|
||||
let ramAvailable: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case nodeId = "node_id"
|
||||
case model
|
||||
case chip
|
||||
case osVersion = "os_version"
|
||||
case friendlyName = "friendly_name"
|
||||
case ramTotal = "ram_total"
|
||||
case ramAvailable = "ram_available"
|
||||
}
|
||||
}
|
||||
|
||||
enum DeviceInfoService {
|
||||
private static let liteNodeIdKey = "exo_lite_node_id"
|
||||
|
||||
static var liteNodeId: String {
|
||||
if let existing = UserDefaults.standard.string(forKey: liteNodeIdKey) {
|
||||
return existing
|
||||
}
|
||||
let newId = UUID().uuidString.lowercased()
|
||||
UserDefaults.standard.set(newId, forKey: liteNodeIdKey)
|
||||
return newId
|
||||
}
|
||||
|
||||
static func gather() -> DeviceInfo {
|
||||
let model = modelName()
|
||||
let chip = chipName(for: model)
|
||||
|
||||
let totalRam = Int(ProcessInfo.processInfo.physicalMemory)
|
||||
let availableRam = availableMemory()
|
||||
|
||||
return DeviceInfo(
|
||||
nodeId: liteNodeId,
|
||||
model: model,
|
||||
chip: chip,
|
||||
osVersion: UIDevice.current.systemVersion,
|
||||
friendlyName: UIDevice.current.name,
|
||||
ramTotal: totalRam,
|
||||
ramAvailable: availableRam
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func modelName() -> String {
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let machine = withUnsafePointer(to: &systemInfo.machine) {
|
||||
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||
String(cString: $0)
|
||||
}
|
||||
}
|
||||
return modelMapping[machine] ?? machine
|
||||
}
|
||||
|
||||
private static func chipName(for model: String) -> String {
|
||||
let lower = model.lowercased()
|
||||
if lower.contains("iphone 16 pro") || lower.contains("iphone 16 pro max") {
|
||||
return "Apple A18 Pro"
|
||||
} else if lower.contains("iphone 16") {
|
||||
return "Apple A18"
|
||||
} else if lower.contains("iphone 15 pro") || lower.contains("iphone 15 pro max") {
|
||||
return "Apple A17 Pro"
|
||||
} else if lower.contains("iphone 15") {
|
||||
return "Apple A16 Bionic"
|
||||
} else if lower.contains("iphone 14 pro") || lower.contains("iphone 14 pro max") {
|
||||
return "Apple A16 Bionic"
|
||||
} else if lower.contains("iphone 14") {
|
||||
return "Apple A15 Bionic"
|
||||
}
|
||||
return "Apple Silicon"
|
||||
}
|
||||
|
||||
private static func availableMemory() -> Int {
|
||||
return Int(os_proc_available_memory())
|
||||
}
|
||||
|
||||
private static let modelMapping: [String: String] = [
|
||||
// iPhone 16 series
|
||||
"iPhone17,1": "iPhone 16 Pro",
|
||||
"iPhone17,2": "iPhone 16 Pro Max",
|
||||
"iPhone17,3": "iPhone 16",
|
||||
"iPhone17,4": "iPhone 16 Plus",
|
||||
// iPhone 15 series
|
||||
"iPhone16,1": "iPhone 15 Pro",
|
||||
"iPhone16,2": "iPhone 15 Pro Max",
|
||||
"iPhone15,4": "iPhone 15",
|
||||
"iPhone15,5": "iPhone 15 Plus",
|
||||
// iPhone 14 series
|
||||
"iPhone15,2": "iPhone 14 Pro",
|
||||
"iPhone15,3": "iPhone 14 Pro Max",
|
||||
"iPhone14,7": "iPhone 14",
|
||||
"iPhone14,8": "iPhone 14 Plus",
|
||||
// iPhone 13 series
|
||||
"iPhone14,2": "iPhone 13 Pro",
|
||||
"iPhone14,3": "iPhone 13 Pro Max",
|
||||
"iPhone14,5": "iPhone 13",
|
||||
"iPhone14,4": "iPhone 13 mini",
|
||||
// Simulator
|
||||
"arm64": "iPhone (Simulator)",
|
||||
"x86_64": "iPhone (Simulator)",
|
||||
]
|
||||
}
|
||||
@@ -186,7 +186,9 @@
|
||||
// topology payload is missing them. Topology order is preserved exactly so
|
||||
// that the mini preview matches the main TopologyGraph layout.
|
||||
const nodeList = $derived(() => {
|
||||
const nodesFromTopology = Object.keys(nodes).map((id) => {
|
||||
const nodesFromTopology = Object.keys(nodes)
|
||||
.filter((id) => nodes[id].node_type !== "lite")
|
||||
.map((id) => {
|
||||
const info = nodes[id];
|
||||
const totalBytes =
|
||||
info.macmon_info?.memory?.ram_total ?? info.system_info?.memory ?? 0;
|
||||
|
||||
@@ -554,6 +554,7 @@
|
||||
const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
||||
|
||||
const modelLower = modelId.toLowerCase();
|
||||
const isLiteNode = node.node_type === "lite";
|
||||
|
||||
// Check node states for styling
|
||||
const isHighlighted = highlightedNodes.has(nodeInfo.id);
|
||||
@@ -906,6 +907,94 @@
|
||||
.attr("height", trackpadHeight)
|
||||
.attr("fill", "rgba(255,255,255,0.08)")
|
||||
.attr("rx", 2);
|
||||
} else if (modelLower.includes("iphone")) {
|
||||
// iPhone - rounded rectangle phone shape with Dynamic Island
|
||||
iconBaseWidth = nodeRadius * 0.7;
|
||||
iconBaseHeight = nodeRadius * 1.4;
|
||||
const x = nodeInfo.x - iconBaseWidth / 2;
|
||||
const y = nodeInfo.y - iconBaseHeight / 2;
|
||||
const cornerRadius = iconBaseWidth * 0.2;
|
||||
const bezel = 3;
|
||||
|
||||
// Phone body (outer frame)
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("class", "node-outline")
|
||||
.attr("x", x)
|
||||
.attr("y", y)
|
||||
.attr("width", iconBaseWidth)
|
||||
.attr("height", iconBaseHeight)
|
||||
.attr("rx", cornerRadius)
|
||||
.attr("fill", "#1a1a1a")
|
||||
.attr("stroke", wireColor)
|
||||
.attr("stroke-width", strokeWidth);
|
||||
|
||||
// Screen area (inner)
|
||||
const screenClipId = `iphone-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
||||
defs
|
||||
.append("clipPath")
|
||||
.attr("id", screenClipId)
|
||||
.append("rect")
|
||||
.attr("x", x + bezel)
|
||||
.attr("y", y + bezel)
|
||||
.attr("width", iconBaseWidth - bezel * 2)
|
||||
.attr("height", iconBaseHeight - bezel * 2)
|
||||
.attr("rx", cornerRadius - 1);
|
||||
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", x + bezel)
|
||||
.attr("y", y + bezel)
|
||||
.attr("width", iconBaseWidth - bezel * 2)
|
||||
.attr("height", iconBaseHeight - bezel * 2)
|
||||
.attr("rx", cornerRadius - 1)
|
||||
.attr("fill", screenFill);
|
||||
|
||||
// Memory fill on screen (fills from bottom up)
|
||||
if (ramUsagePercent > 0) {
|
||||
const memFillTotalHeight = iconBaseHeight - bezel * 2;
|
||||
const memFillActualHeight =
|
||||
(ramUsagePercent / 100) * memFillTotalHeight;
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", x + bezel)
|
||||
.attr(
|
||||
"y",
|
||||
y + bezel + (memFillTotalHeight - memFillActualHeight),
|
||||
)
|
||||
.attr("width", iconBaseWidth - bezel * 2)
|
||||
.attr("height", memFillActualHeight)
|
||||
.attr("fill", "rgba(255,215,0,0.85)")
|
||||
.attr("clip-path", `url(#${screenClipId})`);
|
||||
}
|
||||
|
||||
// Dynamic Island notch (centered near top)
|
||||
const diWidth = iconBaseWidth * 0.3;
|
||||
const diHeight = iconBaseHeight * 0.04;
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", nodeInfo.x - diWidth / 2)
|
||||
.attr("y", y + bezel + 4)
|
||||
.attr("width", diWidth)
|
||||
.attr("height", diHeight)
|
||||
.attr("rx", diHeight / 2)
|
||||
.attr("fill", "#000000");
|
||||
|
||||
// Apple logo on screen (centered)
|
||||
const targetLogoHeight = iconBaseHeight * 0.14;
|
||||
const logoScale = targetLogoHeight / LOGO_NATIVE_HEIGHT;
|
||||
const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale) / 2;
|
||||
const logoY =
|
||||
nodeInfo.y - (LOGO_NATIVE_HEIGHT * logoScale) / 2;
|
||||
nodeG
|
||||
.append("path")
|
||||
.attr("d", APPLE_LOGO_PATH)
|
||||
.attr(
|
||||
"transform",
|
||||
`translate(${logoX}, ${logoY}) scale(${logoScale})`,
|
||||
)
|
||||
.attr("fill", "#FFFFFF")
|
||||
.attr("opacity", 0.9);
|
||||
} else {
|
||||
// Default/Unknown - holographic hexagon
|
||||
const hexRadius = nodeRadius * 0.6;
|
||||
@@ -924,9 +1013,43 @@
|
||||
.attr("stroke-width", strokeWidth);
|
||||
}
|
||||
|
||||
// --- LITE badge for lite nodes ---
|
||||
if (isLiteNode) {
|
||||
const badgeX = nodeInfo.x + iconBaseWidth / 2 + 4;
|
||||
const badgeY = nodeInfo.y - iconBaseHeight / 2 - 2;
|
||||
const badgeFontSize = Math.max(9, nodeRadius * 0.12);
|
||||
const badgePadH = 4;
|
||||
const badgePadV = 2;
|
||||
const badgeWidth = badgeFontSize * 2.8 + badgePadH * 2;
|
||||
const badgeHeight = badgeFontSize + badgePadV * 2;
|
||||
|
||||
nodeG
|
||||
.append("rect")
|
||||
.attr("x", badgeX)
|
||||
.attr("y", badgeY)
|
||||
.attr("width", badgeWidth)
|
||||
.attr("height", badgeHeight)
|
||||
.attr("rx", 3)
|
||||
.attr("fill", "rgba(255,215,0,0.15)")
|
||||
.attr("stroke", "rgba(255,215,0,0.6)")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
nodeG
|
||||
.append("text")
|
||||
.attr("x", badgeX + badgeWidth / 2)
|
||||
.attr("y", badgeY + badgeHeight / 2)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dominant-baseline", "central")
|
||||
.attr("fill", "rgba(255,215,0,0.9)")
|
||||
.attr("font-size", badgeFontSize)
|
||||
.attr("font-weight", "700")
|
||||
.attr("font-family", "SF Mono, Monaco, monospace")
|
||||
.text("LITE");
|
||||
}
|
||||
|
||||
// --- Vertical GPU Bar (right side of icon) ---
|
||||
// Show in both full mode and minimized mode (scaled appropriately)
|
||||
if (showFullLabels || isMinimized) {
|
||||
// Show in both full mode and minimized mode (scaled appropriately), but not for lite nodes
|
||||
if ((showFullLabels || isMinimized) && !isLiteNode) {
|
||||
const gpuBarWidth = isMinimized
|
||||
? Math.max(16, nodeRadius * 0.32)
|
||||
: Math.max(28, nodeRadius * 0.3);
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface NodeInfo {
|
||||
last_macmon_update: number;
|
||||
friendly_name?: string;
|
||||
os_version?: string;
|
||||
node_type?: string;
|
||||
}
|
||||
|
||||
export interface TopologyEdge {
|
||||
@@ -81,6 +82,7 @@ interface RawNodeIdentity {
|
||||
friendlyName?: string;
|
||||
osVersion?: string;
|
||||
osBuildVersion?: string;
|
||||
nodeType?: string;
|
||||
}
|
||||
|
||||
interface RawMemoryUsage {
|
||||
@@ -445,6 +447,7 @@ function transformTopology(
|
||||
last_macmon_update: Date.now() / 1000,
|
||||
friendly_name: identity?.friendlyName,
|
||||
os_version: identity?.osVersion,
|
||||
node_type: identity?.nodeType,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,9 @@
|
||||
const macosVersionMismatch = $derived.by(() => {
|
||||
if (!identitiesData) return null;
|
||||
const entries = Object.entries(identitiesData);
|
||||
// Filter to macOS nodes (version starts with a digit, e.g. "15.3")
|
||||
// Filter to full macOS nodes (version starts with a digit, e.g. "15.3"), excluding lite nodes
|
||||
const macosNodes = entries.filter(([_, id]) => {
|
||||
if (id.nodeType === "lite") return false;
|
||||
const v = id.osVersion;
|
||||
return v && v !== "Unknown" && /^\d/.test(v);
|
||||
});
|
||||
@@ -638,7 +639,9 @@
|
||||
models = data.data || [];
|
||||
// Restore last launch defaults if available
|
||||
const currentNodeCount = topologyData()
|
||||
? Object.keys(topologyData()!.nodes).length
|
||||
? Object.values(topologyData()!.nodes).filter(
|
||||
(n) => n.node_type !== "lite",
|
||||
).length
|
||||
: 1;
|
||||
applyLaunchDefaults(models, currentNodeCount);
|
||||
}
|
||||
@@ -1646,7 +1649,12 @@
|
||||
saveLaunchDefaults();
|
||||
}
|
||||
|
||||
const nodeCount = $derived(data ? Object.keys(data.nodes).length : 0);
|
||||
const totalNodeCount = $derived(data ? Object.keys(data.nodes).length : 0);
|
||||
const fullNodeCount = $derived(
|
||||
data
|
||||
? Object.values(data.nodes).filter((n) => n.node_type !== "lite").length
|
||||
: 0,
|
||||
);
|
||||
const instanceCount = $derived(Object.keys(instanceData).length);
|
||||
|
||||
// Helper to get the number of nodes in a placement preview
|
||||
@@ -1659,7 +1667,7 @@
|
||||
}
|
||||
|
||||
// Available min nodes options based on topology (like old dashboard)
|
||||
const availableMinNodes = $derived(Math.max(1, nodeCount));
|
||||
const availableMinNodes = $derived(Math.max(1, fullNodeCount));
|
||||
|
||||
// Compute which min node values have valid previews for the current model/sharding/instance type
|
||||
// A minNodes value N is valid if there exists a placement with nodeCount >= N
|
||||
@@ -1752,15 +1760,17 @@
|
||||
// Calculate total memory usage across all nodes
|
||||
const clusterMemory = $derived(() => {
|
||||
if (!data) return { used: 0, total: 0 };
|
||||
return Object.values(data.nodes).reduce(
|
||||
(acc, n) => {
|
||||
const total =
|
||||
n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
|
||||
const used = n.macmon_info?.memory?.ram_usage ?? 0;
|
||||
return { used: acc.used + used, total: acc.total + total };
|
||||
},
|
||||
{ used: 0, total: 0 },
|
||||
);
|
||||
return Object.values(data.nodes)
|
||||
.filter((n) => n.node_type !== "lite")
|
||||
.reduce(
|
||||
(acc, n) => {
|
||||
const total =
|
||||
n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
|
||||
const used = n.macmon_info?.memory?.ram_usage ?? 0;
|
||||
return { used: acc.used + used, total: acc.total + total };
|
||||
},
|
||||
{ used: 0, total: 0 },
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3091,7 +3101,7 @@
|
||||
TOPOLOGY
|
||||
</div>
|
||||
<span class="text-xs text-white/70 tabular-nums"
|
||||
>{nodeCount} {nodeCount === 1 ? "NODE" : "NODES"}</span
|
||||
>{totalNodeCount} {totalNodeCount === 1 ? "NODE" : "NODES"}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
|
||||
+36
-6
@@ -85,6 +85,7 @@ from exo.shared.types.api import (
|
||||
ImageGenerationTaskParams,
|
||||
ImageListItem,
|
||||
ImageListResponse,
|
||||
LiteNodeHeartbeatRequest,
|
||||
ModelList,
|
||||
ModelListModel,
|
||||
PlaceInstanceParams,
|
||||
@@ -122,6 +123,7 @@ from exo.shared.types.commands import (
|
||||
ForwarderDownloadCommand,
|
||||
ImageEdits,
|
||||
ImageGeneration,
|
||||
LiteNodeHeartbeat,
|
||||
PlaceInstance,
|
||||
SendInputChunk,
|
||||
StartDownload,
|
||||
@@ -148,6 +150,7 @@ from exo.shared.types.worker.shards import Sharding
|
||||
from exo.utils.banner import print_startup_banner
|
||||
from exo.utils.channels import Receiver, Sender, channel
|
||||
from exo.utils.event_buffer import OrderedBuffer
|
||||
from exo.utils.info_gatherer.info_gatherer import LiteNodeRegistration
|
||||
|
||||
_API_EVENT_LOG_DIR = EXO_EVENT_LOG_DIR / "api"
|
||||
|
||||
@@ -303,6 +306,7 @@ class API:
|
||||
self.app.get("/v1/traces/{task_id}")(self.get_trace)
|
||||
self.app.get("/v1/traces/{task_id}/stats")(self.get_trace_stats)
|
||||
self.app.get("/v1/traces/{task_id}/raw")(self.get_trace_raw)
|
||||
self.app.post("/v1/lite_node/heartbeat")(self.lite_node_heartbeat)
|
||||
|
||||
async def place_instance(self, payload: PlaceInstanceParams):
|
||||
command = PlaceInstance(
|
||||
@@ -365,6 +369,7 @@ class API:
|
||||
node_network=self.state.node_network,
|
||||
topology=self.state.topology,
|
||||
current_instances=self.state.instances,
|
||||
node_identities=self.state.node_identities,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -390,7 +395,14 @@ class API:
|
||||
previews: list[PlacementPreview] = []
|
||||
required_nodes = set(node_ids) if node_ids else None
|
||||
|
||||
if len(list(self.state.topology.list_nodes())) == 0:
|
||||
full_node_count = sum(
|
||||
1
|
||||
for nid in self.state.topology.list_nodes()
|
||||
if self.state.node_identities.get(nid) is None
|
||||
or self.state.node_identities[nid].node_type != "lite"
|
||||
)
|
||||
|
||||
if full_node_count == 0:
|
||||
return PlacementPreviewResponse(previews=[])
|
||||
|
||||
try:
|
||||
@@ -405,9 +417,7 @@ class API:
|
||||
instance_combinations.extend(
|
||||
[
|
||||
(sharding, instance_meta, i)
|
||||
for i in range(
|
||||
1, len(list(self.state.topology.list_nodes())) + 1
|
||||
)
|
||||
for i in range(1, full_node_count + 1)
|
||||
]
|
||||
)
|
||||
# TODO: PDD
|
||||
@@ -427,6 +437,7 @@ class API:
|
||||
topology=self.state.topology,
|
||||
current_instances=self.state.instances,
|
||||
required_nodes=required_nodes,
|
||||
node_identities=self.state.node_identities,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if (model_card.model_id, sharding, instance_meta, 0) not in seen:
|
||||
@@ -1278,11 +1289,30 @@ class API:
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
async def lite_node_heartbeat(self, payload: LiteNodeHeartbeatRequest) -> JSONResponse:
|
||||
info = LiteNodeRegistration(
|
||||
model=payload.model,
|
||||
chip=payload.chip,
|
||||
os_version=payload.os_version,
|
||||
friendly_name=payload.friendly_name,
|
||||
ram_total=payload.ram_total,
|
||||
ram_available=payload.ram_available,
|
||||
)
|
||||
command = LiteNodeHeartbeat(
|
||||
target_node_id=NodeId(payload.node_id),
|
||||
info=info,
|
||||
)
|
||||
await self._send(command)
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
def _calculate_total_available_memory(self) -> Memory:
|
||||
"""Calculate total available memory across all nodes in bytes."""
|
||||
"""Calculate total available memory across all non-lite nodes in bytes."""
|
||||
total_available = Memory()
|
||||
|
||||
for memory in self.state.node_memory.values():
|
||||
for node_id, memory in self.state.node_memory.items():
|
||||
identity = self.state.node_identities.get(node_id)
|
||||
if identity is not None and identity.node_type == "lite":
|
||||
continue
|
||||
total_available += memory.ram_available
|
||||
|
||||
return total_available
|
||||
|
||||
@@ -21,6 +21,7 @@ from exo.shared.types.commands import (
|
||||
ForwarderDownloadCommand,
|
||||
ImageEdits,
|
||||
ImageGeneration,
|
||||
LiteNodeHeartbeat,
|
||||
PlaceInstance,
|
||||
RequestEventLog,
|
||||
SendInputChunk,
|
||||
@@ -299,6 +300,7 @@ class Master:
|
||||
self.state.instances,
|
||||
self.state.node_memory,
|
||||
self.state.node_network,
|
||||
node_identities=self.state.node_identities,
|
||||
)
|
||||
transition_events = get_transition_events(
|
||||
self.state.instances, placement, self.state.tasks
|
||||
@@ -344,6 +346,14 @@ class Master:
|
||||
self.command_task_mapping.pop(
|
||||
command.finished_command_id, None
|
||||
)
|
||||
case LiteNodeHeartbeat():
|
||||
generated_events.append(
|
||||
NodeGatheredInfo(
|
||||
node_id=command.target_node_id,
|
||||
when=str(datetime.now(tz=timezone.utc)),
|
||||
info=command.info,
|
||||
)
|
||||
)
|
||||
case RequestEventLog():
|
||||
# We should just be able to send everything, since other buffers will ignore old messages
|
||||
# rate limit to 1000 at a time
|
||||
|
||||
@@ -29,7 +29,7 @@ from exo.shared.types.events import (
|
||||
TaskStatusUpdated,
|
||||
)
|
||||
from exo.shared.types.memory import Memory
|
||||
from exo.shared.types.profiling import MemoryUsage, NodeNetworkInfo
|
||||
from exo.shared.types.profiling import MemoryUsage, NodeIdentity, NodeNetworkInfo
|
||||
from exo.shared.types.tasks import Task, TaskId, TaskStatus
|
||||
from exo.shared.types.worker.downloads import (
|
||||
DownloadOngoing,
|
||||
@@ -67,8 +67,21 @@ def place_instance(
|
||||
node_memory: Mapping[NodeId, MemoryUsage],
|
||||
node_network: Mapping[NodeId, NodeNetworkInfo],
|
||||
required_nodes: set[NodeId] | None = None,
|
||||
node_identities: Mapping[NodeId, NodeIdentity] = {},
|
||||
) -> dict[InstanceId, Instance]:
|
||||
lite_node_ids = {
|
||||
nid
|
||||
for nid, identity in node_identities.items()
|
||||
if identity.node_type == "lite"
|
||||
}
|
||||
cycles = topology.get_cycles()
|
||||
if lite_node_ids:
|
||||
filtered_cycles: list[Cycle] = []
|
||||
for cycle in cycles:
|
||||
filtered_nodes = [nid for nid in cycle.node_ids if nid not in lite_node_ids]
|
||||
if filtered_nodes:
|
||||
filtered_cycles.append(Cycle(filtered_nodes))
|
||||
cycles = filtered_cycles
|
||||
candidate_cycles = list(filter(lambda it: len(it) >= command.min_nodes, cycles))
|
||||
|
||||
# Filter to cycles containing all required nodes (subset matching)
|
||||
|
||||
@@ -42,6 +42,7 @@ from exo.shared.types.worker.downloads import DownloadProgress
|
||||
from exo.shared.types.worker.instances import Instance, InstanceId
|
||||
from exo.shared.types.worker.runners import RunnerId, RunnerStatus
|
||||
from exo.utils.info_gatherer.info_gatherer import (
|
||||
LiteNodeRegistration,
|
||||
MacmonMetrics,
|
||||
MacThunderboltConnections,
|
||||
MacThunderboltIdentifiers,
|
||||
@@ -243,6 +244,11 @@ def apply_node_timed_out(event: NodeTimedOut, state: State) -> State:
|
||||
node_rdma_ctl = {
|
||||
key: value for key, value in state.node_rdma_ctl.items() if key != event.node_id
|
||||
}
|
||||
node_identities = {
|
||||
key: value
|
||||
for key, value in state.node_identities.items()
|
||||
if key != event.node_id
|
||||
}
|
||||
# Only recompute cycles if the leaving node had TB bridge enabled
|
||||
leaving_node_status = state.node_thunderbolt_bridge.get(event.node_id)
|
||||
leaving_node_had_tb_enabled = (
|
||||
@@ -265,6 +271,7 @@ def apply_node_timed_out(event: NodeTimedOut, state: State) -> State:
|
||||
"node_thunderbolt": node_thunderbolt,
|
||||
"node_thunderbolt_bridge": node_thunderbolt_bridge,
|
||||
"node_rdma_ctl": node_rdma_ctl,
|
||||
"node_identities": node_identities,
|
||||
"thunderbolt_bridge_cycles": thunderbolt_bridge_cycles,
|
||||
}
|
||||
)
|
||||
@@ -371,6 +378,30 @@ def apply_node_gathered_info(event: NodeGatheredInfo, state: State) -> State:
|
||||
**state.node_rdma_ctl,
|
||||
event.node_id: NodeRdmaCtlStatus(enabled=info.enabled),
|
||||
}
|
||||
case LiteNodeRegistration():
|
||||
current_identity = state.node_identities.get(event.node_id, NodeIdentity())
|
||||
new_identity = current_identity.model_copy(
|
||||
update={
|
||||
"model_id": info.model,
|
||||
"chip_id": info.chip,
|
||||
"os_version": info.os_version,
|
||||
"friendly_name": info.friendly_name,
|
||||
"node_type": "lite",
|
||||
}
|
||||
)
|
||||
update["node_identities"] = {
|
||||
**state.node_identities,
|
||||
event.node_id: new_identity,
|
||||
}
|
||||
update["node_memory"] = {
|
||||
**state.node_memory,
|
||||
event.node_id: MemoryUsage.from_bytes(
|
||||
ram_total=info.ram_total,
|
||||
ram_available=info.ram_available,
|
||||
swap_total=0,
|
||||
swap_available=0,
|
||||
),
|
||||
}
|
||||
|
||||
return state.model_copy(update=update)
|
||||
|
||||
|
||||
@@ -406,3 +406,13 @@ class TraceListItem(CamelCaseModel):
|
||||
|
||||
class TraceListResponse(CamelCaseModel):
|
||||
traces: list[TraceListItem]
|
||||
|
||||
|
||||
class LiteNodeHeartbeatRequest(CamelCaseModel):
|
||||
node_id: str
|
||||
model: str
|
||||
chip: str
|
||||
os_version: str
|
||||
friendly_name: str
|
||||
ram_total: int
|
||||
ram_available: int
|
||||
|
||||
@@ -10,6 +10,7 @@ from exo.shared.types.common import CommandId, NodeId
|
||||
from exo.shared.types.text_generation import TextGenerationTaskParams
|
||||
from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
|
||||
from exo.shared.types.worker.shards import Sharding, ShardMetadata
|
||||
from exo.utils.info_gatherer.info_gatherer import GatheredInfo
|
||||
from exo.utils.pydantic_ext import CamelCaseModel, TaggedModel
|
||||
|
||||
|
||||
@@ -62,6 +63,11 @@ class SendInputChunk(BaseCommand):
|
||||
chunk: InputImageChunk
|
||||
|
||||
|
||||
class LiteNodeHeartbeat(BaseCommand):
|
||||
target_node_id: NodeId
|
||||
info: GatheredInfo
|
||||
|
||||
|
||||
class RequestEventLog(BaseCommand):
|
||||
since_idx: int
|
||||
|
||||
@@ -96,6 +102,7 @@ Command = (
|
||||
| TaskCancelled
|
||||
| TaskFinished
|
||||
| SendInputChunk
|
||||
| LiteNodeHeartbeat
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ class SystemPerformanceProfile(CamelCaseModel):
|
||||
|
||||
InterfaceType = Literal["wifi", "ethernet", "maybe_ethernet", "thunderbolt", "unknown"]
|
||||
|
||||
NodeType = Literal["full", "lite"]
|
||||
|
||||
|
||||
class NetworkInterfaceInfo(CamelCaseModel):
|
||||
name: str
|
||||
@@ -83,6 +85,7 @@ class NodeIdentity(CamelCaseModel):
|
||||
friendly_name: str = "Unknown"
|
||||
os_version: str = "Unknown"
|
||||
os_build_version: str = "Unknown"
|
||||
node_type: NodeType = "full"
|
||||
|
||||
|
||||
class NodeNetworkInfo(CamelCaseModel):
|
||||
|
||||
@@ -334,6 +334,17 @@ class NodeDiskUsage(TaggedModel):
|
||||
)
|
||||
|
||||
|
||||
class LiteNodeRegistration(TaggedModel):
|
||||
"""Device info reported by a lite node (e.g. iPhone) via heartbeat."""
|
||||
|
||||
model: str # e.g. "iPhone 16 Pro"
|
||||
chip: str # e.g. "Apple A18 Pro"
|
||||
os_version: str # e.g. "18.3"
|
||||
friendly_name: str # e.g. "Sami's iPhone"
|
||||
ram_total: int # bytes
|
||||
ram_available: int # bytes
|
||||
|
||||
|
||||
async def _gather_iface_map() -> dict[str, str] | None:
|
||||
proc = await anyio.run_process(
|
||||
["networksetup", "-listallhardwareports"], check=False
|
||||
@@ -366,6 +377,7 @@ GatheredInfo = (
|
||||
| MiscData
|
||||
| StaticNodeInformation
|
||||
| NodeDiskUsage
|
||||
| LiteNodeRegistration
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user