| import {measureAsync, sleep} from './utils.js'; |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export class RequestManager { |
| constructor({ |
| deviceService, |
| cloudService, |
| evaluator, |
| logger = null, |
| routeStrategy = 'roundrobin', |
| cloudProb = 0.5, |
| devicePerfModel = {slope: 0, intercept: 0}, |
| cloudPerfModel = {slope: 0, intercept: 0} |
| } = {}) { |
|
|
| |
| |
| |
| this.device = deviceService; |
|
|
| |
| |
| |
| this.cloud = cloudService; |
|
|
| |
| |
| |
| this.evaluator = evaluator; |
|
|
| |
| |
| |
| |
| this.logger = logger; |
|
|
| |
| |
| |
| |
| this.routeStrategy = routeStrategy; |
|
|
| |
| |
| |
| |
| this.cloudProb = cloudProb; |
|
|
| |
| |
| |
| |
| this.devicePerfModel = devicePerfModel; |
|
|
| |
| |
| |
| |
| this.cloudPerfModel = cloudPerfModel; |
|
|
| |
| const deviceMu0 = [this.devicePerfModel.intercept || 0, this.devicePerfModel.slope || 0]; |
| const cloudMu0 = [this.cloudPerfModel.intercept || 0, this.cloudPerfModel.slope || 0]; |
|
|
| |
| const priorLambda = 1e-3; |
| const observationSigma2 = 1e4; |
|
|
| this.tsDevice = new LinearThompsonSampler(deviceMu0, priorLambda, observationSigma2); |
| this.tsCloud = new LinearThompsonSampler(cloudMu0, priorLambda, observationSigma2); |
|
|
| |
| |
| |
| |
| |
| this._rrCounter = 0; |
|
|
| |
| |
| |
| |
| this.stats = {count: 0, cloud: 0, device: 0, totalLatencyMs: 0, results: []}; |
|
|
| |
| |
| |
| |
| this.cloud_queue = []; |
|
|
| |
| |
| |
| |
| this.device_queue = []; |
|
|
| |
| this.runOnDeviceJobsFromQueue(); |
| this.runCloudJobsFromQueue(); |
| } |
|
|
| |
| |
| |
| |
| |
| pushJob(job) { |
| |
| const route = this._choose(job); |
| console.log(`Device Queue Length: ${this.device_queue.length}, \nCloud Queue Length: ${this.cloud_queue.length}`); |
|
|
| if (route === 'cloud') { |
| this.cloud_queue.push(job); |
| } else { |
| this.device_queue.push(job); |
| } |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| updateRouting({routeStrategy, cloudProb, devicePerfModel, cloudPerfModel}) { |
| if (routeStrategy) this.routeStrategy = routeStrategy; |
| if (cloudProb !== undefined) this.cloudProb = cloudProb; |
| if (devicePerfModel) this.devicePerfModel = devicePerfModel; |
| if (cloudPerfModel) this.cloudPerfModel = cloudPerfModel; |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| async runOnDeviceJobsFromQueue() { |
| while (true) { |
| if (this.device_queue.length > 0) { |
| const job = this._getNextJobFromQueue(this.device_queue, 'fifo'); |
| const service = this.device; |
| const route = 'device'; |
|
|
| |
| await this._runJob(job, route, service); |
| } |
|
|
| |
| await sleep(10); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| async runCloudJobsFromQueue() { |
| while (true) { |
| if (this.cloud_queue.length > 0) { |
| const job = this._getNextJobFromQueue(this.cloud_queue, 'fifo'); |
| const service = this.cloud; |
| const route = 'cloud'; |
|
|
| |
| await this._runJob(job, route, service); |
| } |
|
|
| |
| await sleep(10); |
| } |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async _runJob(job, route, service) { |
| let full_prompt = job.prompt; |
|
|
| |
| if (service.getModelName().toLowerCase().includes("qwen3".toLowerCase())) { |
| full_prompt = full_prompt; |
| console.log("ℹ️ \"/no_think\" was added to the prompt to avoid thinking") |
| } |
|
|
| let response, latencyMs, cleanedResponse; |
| try { |
| |
| job.timestamps.inferenceStart = Date.now(); |
|
|
| const {res, ms} = await measureAsync(() => service.infer(full_prompt)); |
| response = res; |
| latencyMs = ms; |
|
|
| |
| job.timestamps.inferenceEnd = Date.now(); |
| } catch (err) { |
| response = `__error__:${err.message}`; |
| latencyMs = -1; |
| job.timestamps.inferenceEnd = Date.now(); |
| } |
|
|
| |
| const queueingTime = job.timestamps.inferenceStart - job.timestamps.jobStart; |
| const inferenceTime = job.timestamps.inferenceEnd - job.timestamps.inferenceStart; |
| const totalLatency = job.timestamps.inferenceEnd - job.timestamps.jobStart; |
|
|
|
|
| |
| cleanedResponse = this._cleanResponse(response); |
|
|
| |
| const evalRes = this.evaluator.evaluate(cleanedResponse, job.groundTruth, latencyMs); |
| this._record(route, latencyMs, evalRes, job, cleanedResponse, {queueingTime, inferenceTime, totalLatency}); |
|
|
| if (this.logger) { |
| try { |
| this.logger({job, route, latency: latencyMs, evalRes, response: cleanedResponse.answer, queueingTime, inferenceTime, totalLatency}); |
| } catch (error) { |
| console.error("Logger encountered an error:", error); |
| } |
| } |
|
|
| |
| try { |
| if (latencyMs > 0) { |
| const x = [1, job.prompt.length]; |
| const y = inferenceTime; |
| if (route === 'device') { |
| this.tsDevice.update(x, y); |
| } else { |
| this.tsCloud.update(x, y); |
| } |
| |
| this._updateLinearModelsForHERO(); |
| } |
| } catch (err) { |
| console.warn("TS update failed:", err); |
| } |
|
|
| |
| console.log(cleanedResponse) |
| console.log("🎯 Models Answer: " + response.answer + |
| "; \nCleaned Answer: " + cleanedResponse.answer + |
| '; \nGround Truth: ' + job.groundTruth + |
| "; \nInference Time: " + inferenceTime.toFixed(2) + "ms" + |
| "; \nQueueing Time: " + queueingTime.toFixed(2) + "ms" + |
| "; \nTotal Latency: " + totalLatency.toFixed(2) + "ms"); |
| } |
|
|
| _getNextJobFromQueue(queue, policy) { |
| |
| return queue.shift(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| _choose(job) { |
| switch (this.routeStrategy) { |
| case 'always_cloud': |
| return 'cloud'; |
| case 'always_device': |
| return 'device'; |
| case 'probabilistic': |
| return Math.random() < this.cloudProb ? 'cloud' : 'device'; |
| case 'roundrobin': |
| this._rrCounter++; |
| return this._rrCounter % 2 === 0 ? 'cloud' : 'device'; |
| case 'hero': |
| return this._decideHERO(job); |
| default: |
| return 'device'; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| _decideHERO(job) { |
| const now = Date.now(); |
| const input_size = job.prompt.length; |
|
|
| |
| const thetaDevice = this.tsDevice.sampleTheta(); |
| const thetaCloud = this.tsCloud.sampleTheta(); |
|
|
| |
| const device_predicted_inference_time = thetaDevice[0] + thetaDevice[1] * input_size; |
| const cloud_predicted_inference_time = thetaCloud[0] + thetaCloud[1] * input_size; |
|
|
|
|
| |
| const lastDeviceJob = this.device_queue.length > 0 ? this.device_queue[this.device_queue.length - 1] : null; |
| const lastCloudJob = this.cloud_queue.length > 0 ? this.cloud_queue[this.cloud_queue.length - 1] : null; |
|
|
| |
| const device_free_at = Math.max(now, lastDeviceJob?.hero_predictions?.device?.expectedFinishTime || 0); |
| const cloud_free_at = Math.max(now, lastCloudJob?.hero_predictions?.cloud?.expectedFinishTime || 0); |
|
|
| |
| const device_expected_finish_time = device_free_at + device_predicted_inference_time; |
| const cloud_expected_finish_time = cloud_free_at + cloud_predicted_inference_time; |
|
|
| |
| const device_expected_total_time = device_expected_finish_time - now; |
| const cloud_expected_total_time = cloud_expected_finish_time - now; |
|
|
| |
| job.hero_predictions = { |
| device: { |
| predictedInferenceTime: device_predicted_inference_time, |
| predictedTotalTime: device_expected_total_time, |
| expectedFinishTime: device_expected_finish_time, |
| numberOfJobsInQueue: this.device_queue.length || 0 |
| }, |
| cloud: { |
| predictedInferenceTime: cloud_predicted_inference_time, |
| predictedTotalTime: cloud_expected_total_time, |
| expectedFinishTime: cloud_expected_finish_time, |
| numberOfJobsInQueue: this.cloud_queue.length || 0 |
| } |
| }; |
|
|
|
|
| |
| if (device_expected_finish_time <= cloud_expected_finish_time) { |
| return 'device'; |
| } else { |
| return 'cloud'; |
| } |
| } |
|
|
| |
| |
| |
| _updateLinearModelsForHERO() { |
| const devMean = this.tsDevice.posteriorMean(); |
| const cloudMean = this.tsCloud.posteriorMean(); |
|
|
| |
| this.devicePerfModel = { intercept: devMean[0], slope: devMean[1] }; |
| this.cloudPerfModel = { intercept: cloudMean[0], slope: cloudMean[1] }; |
|
|
| |
| if (typeof window !== 'undefined') { |
| window.dispatchEvent(new CustomEvent('perfModelsUpdated', {detail: { device: this.devicePerfModel, cloud: this.cloudPerfModel }})); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _record(route, latency, evalRes, job, text, timingMetrics) { |
| this.stats.count++; |
| if (route === 'cloud') this.stats.cloud++; else this.stats.device++; |
| if (latency > 0) this.stats.totalLatencyMs += latency; |
| this.stats.results.push({ |
| job: job, |
| route, |
| latency, |
| evalRes, |
| text, |
| queueingTime: timingMetrics.queueingTime, |
| inferenceTime: timingMetrics.inferenceTime, |
| totalLatency: timingMetrics.totalLatency, |
| timestamps: job.timestamps |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _cleanResponse(response) { |
| |
| if (typeof response === 'string' && response.startsWith('__error__')) { |
| return response; |
| } |
|
|
| |
| const cleanedResponse = { ...response }; |
| |
| if (!cleanedResponse.answer || typeof cleanedResponse.answer !== 'string') { |
| return cleanedResponse; |
| } |
|
|
| let cleanedAnswer = cleanedResponse.answer; |
|
|
| |
| const thinkingPatterns = [ |
| |
| /<think>[\s\S]*?<\/think>/gi, |
| /<thinking>[\s\S]*?<\/thinking>/gi, |
| /<reasoning>[\s\S]*?<\/reasoning>/gi, |
| /<thought>[\s\S]*?<\/thought>/gi, |
| |
| |
| /<\|startofthinking\|>[\s\S]*?<\|endofthinking\|>/gi, |
| /<\|reasoning_start\|>[\s\S]*?<\|reasoning_end\|>/gi, |
| |
| |
| /\[THINKING\][\s\S]*?\[\/THINKING\]/gi, |
| /\[REASONING\][\s\S]*?\[\/REASONING\]/gi, |
| /\[THOUGHT\][\s\S]*?\[\/THOUGHT\]/gi, |
| |
| |
| /\*\*Thinking:\*\*[\s\S]*?(?=\*\*Answer:\*\*|$)/gi, |
| /\*\*Reasoning:\*\*[\s\S]*?(?=\*\*Answer:\*\*|$)/gi, |
| ]; |
|
|
| |
| for (const pattern of thinkingPatterns) { |
| cleanedAnswer = cleanedAnswer.replace(pattern, ''); |
| } |
|
|
| |
| cleanedAnswer = cleanedAnswer.trim(); |
| |
| |
| if (cleanedAnswer.length === 0 && cleanedResponse.answer.length > 0) { |
| console.warn('⚠️ Thinking token removal resulted in empty answer. Keeping original.'); |
| return cleanedResponse; |
| } |
|
|
| cleanedResponse.answer = cleanedAnswer; |
| return cleanedResponse; |
| } |
| } |
|
|
|
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| class LinearThompsonSampler { |
| constructor(mu0 = [0, 0], priorLambda = 1e-3, sigma2 = 1e4) { |
| |
| |
| |
| this.mu0 = [...mu0]; |
| this.priorLambda = priorLambda; |
| this.sigma2 = sigma2; |
|
|
| |
| this.A = [ |
| [priorLambda, 0], |
| [0, priorLambda] |
| ]; |
|
|
| |
| this.b = [ |
| priorLambda * this.mu0[0], |
| priorLambda * this.mu0[1] |
| ]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| sampleTheta() { |
| const cov = invert2x2(this.A); |
| const mu = matVecMul(cov, this.b); |
|
|
| |
| const L = cholesky2x2(cov); |
| const z0 = randn(), z1 = randn(); |
| |
| const theta0 = mu[0] + L[0][0] * z0; |
| const theta1 = mu[1] + L[1][0] * z0 + L[1][1] * z1; |
| return [theta0, theta1]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| update(x, y) { |
| |
| const factor = 1.0 / this.sigma2; |
| this.A[0][0] += factor * x[0] * x[0]; |
| this.A[0][1] += factor * x[0] * x[1]; |
| this.A[1][0] += factor * x[1] * x[0]; |
| this.A[1][1] += factor * x[1] * x[1]; |
|
|
| |
| this.b[0] += factor * x[0] * y; |
| this.b[1] += factor * x[1] * y; |
| } |
|
|
| |
| |
| |
| |
| |
| posteriorMean() { |
| const cov = invert2x2(this.A); |
| return matVecMul(cov, this.b); |
| } |
| } |
|
|
| |
|
|
| function matVecMul(mat, vec) { |
| return [ |
| mat[0][0] * vec[0] + mat[0][1] * vec[1], |
| mat[1][0] * vec[0] + mat[1][1] * vec[1] |
| ]; |
| } |
|
|
| function invert2x2(m) { |
| |
| const a = m[0][0], b = m[0][1], c = m[1][0], d = m[1][1]; |
| const det = a * d - b * c; |
| const eps = 1e-12; |
| const detSafe = Math.abs(det) < eps ? (det >= 0 ? eps : -eps) : det; |
| const invDet = 1.0 / detSafe; |
| return [ |
| [ d * invDet, -b * invDet ], |
| [ -c * invDet, a * invDet ] |
| ]; |
| } |
|
|
| function cholesky2x2(m) { |
| |
| const a = m[0][0]; |
| const b = m[0][1]; |
| const c = m[1][1]; |
| const l00 = Math.sqrt(Math.max(a, 1e-12)); |
| const l10 = b / l00; |
| const l11 = Math.sqrt(Math.max(c - l10 * l10, 1e-12)); |
| return [ |
| [l00, 0], |
| [l10, l11] |
| ]; |
| } |
|
|
| function randn() { |
| |
| let u = 0, v = 0; |
| while (u === 0) u = Math.random(); |
| while (v === 0) v = Math.random(); |
| return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); |
| } |