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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,18 @@ Changes you will notice:
then analyzed the same files again file by file, in parallel, and restarted itself when it ended.
That doubled GPU work, showed each "analyzed" notification more than once, kept the progress count
low, and could leave no room for the vision model, so an image fell back to OCR. A new run now
waits for the earlier batch and reuses its cached results.
waits for the earlier batch, shows what it finished, and then analyzes only the files it did not
finish.
- **An image whose vision step failed is no longer cached with its OCR-only result.** The next
analysis of that image (within the 30-minute cache window) tries the vision model again.
- **The smart-folder watcher waits for files you are analyzing in Discover** instead of analyzing
them at the same time, then skips them once the analysis history has them.
- **Audio and video no longer swap Whisper and the text model for every file** on a GPU too small
for both. When the text model already holds a dedicated GPU, Whisper runs on the CPU beside it;
Macs with unified memory are unchanged.
- **The smart-folder watcher runs files of the same kind together.** It keeps taking documents,
images, or audio/video, whichever it just ran, while more are waiting (up to 25 in a row), so a
model loads once per group instead of once per file.
- **On a GPU too small for both models, the text model stays loaded between files.** Loading the
~200 MB embedding model used to evict a 7B text model on a 6 GB card and reload it for the next
file, about 4-5 s per file. The embedding model now runs on the CPU beside it.
Expand Down
45 changes: 41 additions & 4 deletions src/main/services/SmartFolderWatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ const { isUserAnalysisInFlight } = require('../analysis/userAnalysisInFlight');
// How long a file Discover is analyzing right now waits before the watcher
// checks it again.
const USER_ANALYSIS_DEFER_MS = 15000;

// The queue keeps taking files of the kind it just ran (documents, images,
// or audio/video) while any are waiting, so each model loads once per run of
// them instead of swapping with every file. After this many picks in a row it
// goes back to plain queue order so no kind waits indefinitely.
const MAX_LANE_STREAK = 25;

/**
* @param {string} filePath
* @returns {'image' | 'media' | 'document'}
*/
function analysisLaneOf(filePath) {
if (isImagePath(filePath)) return 'image';
if (isMediaPath(filePath)) return 'media';
return 'document';
}
const { isUNCPath } = require('../../shared/crossPlatformUtils');
const { normalizeKeywords } = require('../../shared/normalization');
const {
Expand Down Expand Up @@ -296,6 +312,9 @@ class SmartFolderWatcher {
// they'd be absent from the durable record for that whole window.
/** @type {Map<string, any>} */
this._inFlightItems = new Map();
/** @type {'image' | 'media' | 'document' | null} */
this._lastLane = null;
this._laneStreak = 0;
// Abort controllers of the analyses running now, so stop() can cancel
// them instead of waiting them out.
/** @type {Set<AbortController>} */
Expand Down Expand Up @@ -2254,15 +2273,32 @@ class SmartFolderWatcher {
// case-variant duplicates are caught.
const procKey = (/** @type {any} */ item) => item.dedupKey || item.filePath;
const isRunnable = this._makeRunnableCheck();
const firstAvailableIndex = this.analysisQueue.findIndex(isRunnable);
let firstAvailableIndex = this.analysisQueue.findIndex(isRunnable);

// If all queued items are processing or waiting, wait for the queue timer
if (firstAvailableIndex === -1) {
return;
}

// Stay with the kind of file just analyzed while one is waiting (see
// MAX_LANE_STREAK), so documents, images and audio/video each run
// together instead of swapping models file by file.
if (this._lastLane && this._laneStreak < MAX_LANE_STREAK) {
const sameLane = this.analysisQueue.findIndex(
(item) => isRunnable(item) && analysisLaneOf(item.filePath) === this._lastLane
);
if (sameLane !== -1) firstAvailableIndex = sameLane;
}

const firstItem = this.analysisQueue[firstAvailableIndex];
const firstIsImage = isImagePath(firstItem.filePath);
const lane = analysisLaneOf(firstItem.filePath);
if (lane === this._lastLane) {
this._laneStreak++;
} else {
this._lastLane = lane;
this._laneStreak = 1;
}
const firstIsImage = lane === 'image';

if (firstIsImage) {
// Image batch: take consecutive images (up to 20) that are not currently processing
Expand Down Expand Up @@ -2359,14 +2395,15 @@ class SmartFolderWatcher {
}
}
} else {
// Document batch: take up to maxConcurrentAnalysis that are not currently processing
// Document or audio/video batch, one kind at a time: up to
// maxConcurrentAnalysis runnable items of this lane
const batchSize = Math.max(1, this.maxConcurrentAnalysis || 1);
const batch = [];
let i = firstAvailableIndex;

while (i < this.analysisQueue.length && batch.length < batchSize) {
const item = this.analysisQueue[i];
if (isRunnable(item) && !isImagePath(item.filePath)) {
if (isRunnable(item) && analysisLaneOf(item.filePath) === lane) {
batch.push(item);
this.analysisQueue.splice(i, 1);
// Keep the item in the durable record while it is off both queues.
Expand Down
41 changes: 39 additions & 2 deletions src/main/services/WhisperService.js
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,8 @@ class WhisperService extends EventEmitter {
this._activeTranscriptions = new Set();
this._cache = new Map(); // key -> { result, expiresAt }
this._memoryRegistration = null; // { manager, type } once registered
/** Whisper runs on the CPU because the text model fills the GPU. */
this._cpuBesideText = false;
// Cache-miss dedup: when two callers race the same file before either
// populates the cache, both would otherwise transcode + transcribe
// independently. The whisper context is a single shared resource so
Expand Down Expand Up @@ -953,7 +955,15 @@ class WhisperService extends EventEmitter {
return { type: 'cpu', name: 'CPU' };
})
);
const useGpu = shouldUseGpu(this._gpuInfo);
// On a dedicated GPU already holding the text model, a GPU Whisper did
// not fit beside it: loading one evicted the other, and every audio or
// video file swapped both models. Run Whisper on the CPU there instead
// (whisper.cpp takes useGpu: false with any build variant).
const besideText = shouldUseGpu(this._gpuInfo)
? await this._shouldRunOnCpuBesideText(modelPath)
: false;
this._cpuBesideText = besideText;
const useGpu = shouldUseGpu(this._gpuInfo) && !besideText;
const variantChain = whisperVariantFor(this._gpuInfo);

if (variantChain.includes('cuda')) {
Expand Down Expand Up @@ -985,7 +995,8 @@ class WhisperService extends EventEmitter {
useGpu,
backend: this._gpuInfo?.type || 'cpu',
variantChain,
resolvedVariant
resolvedVariant,
cpuBesideText: besideText
});
this._resolvedVariant = resolvedVariant;

Expand Down Expand Up @@ -2129,6 +2140,29 @@ class WhisperService extends EventEmitter {
return 200 * 1024 * 1024;
}

/**
* Whether to run Whisper on the CPU because the text model holds a
* dedicated GPU that cannot also fit it. Never on unified memory, where CPU
* and GPU share the same budget and moving Whisper would gain nothing.
* @param {string} modelPath
* @returns {Promise<boolean>}
* @private
*/
async _shouldRunOnCpuBesideText(modelPath) {
const manager = this._getMemoryManager();
if (!manager?.getLoadedContext?.('text')) return false;
if (typeof manager.canFitExternalLoad !== 'function') return false;
const gpu = /** @type {any} */ (this._gpuInfo);
if (gpu?.type === 'metal' || gpu?.isAppleSilicon || gpu?.unifiedMemory) return false;
let sizeBytes;
try {
sizeBytes = await this._computeMemoryEstimate(modelPath);
} catch {
sizeBytes = 200 * 1024 * 1024;
}
return manager.canFitExternalLoad(sizeBytes) === false;
}

/**
* Register this service's VRAM usage with the shared memory manager
* so it participates in eviction decisions. Best-effort; failures are
Expand All @@ -2154,6 +2188,9 @@ class WhisperService extends EventEmitter {
});
sizeBytes = 200 * 1024 * 1024;
}
// On the CPU beside the text model it uses system RAM, not the GPU budget
// the manager tracks; counting it there made the next text load evict it.
if (this._cpuBesideText) sizeBytes = 0;

try {
const dispose = async () => {
Expand Down
52 changes: 47 additions & 5 deletions src/renderer/phases/discover/batchBusy.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
* worker pool, which analyzed the same files a second time in parallel with
* the running batch: duplicate notifications, two runs fighting over the GPU,
* and a restart loop when the second run requeued its files. Instead the run
* waits for the earlier batch to finish and then runs its own; files the
* earlier batch finished come back from the analysis cache.
* waits for the earlier batch to finish and then runs only the files it did
* not finish. Those are read from the durable ready queue, not the analysis
* cache, which keeps results for only 30 minutes and a few hundred files.
*/

/** How often a waiting run tries the batch again. */
Expand Down Expand Up @@ -55,28 +56,36 @@ function wait(ms, signal) {
* progress. Returns without running when this run is superseded or aborted
* while it waits. Any other error is rethrown for the caller's fallback.
*
* selectRemaining runs before each retry: it returns the files still to
* analyze (an empty list ends the run, nothing left), or null to keep the
* previous list. runBatchPath receives that list (undefined on the first try).
*
* @param {{
* runBatchPath: () => Promise<any>,
* runBatchPath: (files?: any[]) => Promise<any>,
* isActiveRun: () => boolean,
* abortSignal?: AbortSignal | null,
* onWaiting?: () => void,
* selectRemaining?: () => Promise<any[] | null>,
* retryMs?: number,
* sleep?: (ms: number, signal?: AbortSignal | null) => Promise<void>
* }} options
* @returns {Promise<'ran' | 'superseded'>}
* @returns {Promise<'ran' | 'superseded' | 'finished-elsewhere'>}
*/
export async function runBatchPathWhenFree({
runBatchPath,
isActiveRun,
abortSignal = null,
onWaiting,
selectRemaining,
retryMs = BATCH_BUSY_RETRY_MS,
sleep = wait
}) {
let announced = false;
/** @type {any[] | undefined} */
let files;
for (;;) {
try {
await runBatchPath();
await runBatchPath(files);
return 'ran';
} catch (error) {
if (!isBatchAlreadyRunning(error)) throw error;
Expand All @@ -88,5 +97,38 @@ export async function runBatchPathWhenFree({
}
await sleep(retryMs, abortSignal);
if (!isActiveRun() || abortSignal?.aborted) return 'superseded';
if (selectRemaining) {
const remaining = await selectRemaining();
if (!isActiveRun() || abortSignal?.aborted) return 'superseded';
if (Array.isArray(remaining)) {
if (remaining.length === 0) return 'finished-elsewhere';
files = remaining;
}
}
}
}

/**
* The files of a run that the ready queue does not already hold a current
* result for. A ready entry counts only when the file has not changed since
* it was analyzed (same modified time, when both are known).
* @param {any[]} files - the run's files ({ path, modified? })
* @param {any[]} readyEntries - ready queue entries ({ path, modified?, analysis })
* @param {(p: string) => string} canonical - path key
* @returns {any[]}
*/
export function filesNotYetReady(files, readyEntries, canonical) {
/** @type {Map<string, any>} */
const ready = new Map();
for (const entry of Array.isArray(readyEntries) ? readyEntries : []) {
if (!entry?.path || !entry.analysis) continue;
ready.set(canonical(entry.path), entry);
}
return (Array.isArray(files) ? files : []).filter((file) => {
const entry = ready.get(canonical(file?.path));
if (!entry) return true;
const a = file?.modified ? Date.parse(file.modified) : NaN;
const b = entry.modified ? Date.parse(entry.modified) : NaN;
return Number.isFinite(a) && Number.isFinite(b) && a !== b;
});
}
46 changes: 38 additions & 8 deletions src/renderer/phases/discover/useAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
import { mapErrorToNotification } from '../../utils/errorMapping';
import { normalizeNamingConventionState } from '../../../shared/namingConventions';
import { toCanonicalPath } from '../../utils/canonicalPath';
import { isBatchAlreadyRunning, runBatchPathWhenFree } from './batchBusy';
import { filesNotYetReady, isBatchAlreadyRunning, runBatchPathWhenFree } from './batchBusy';

const logger = createLogger('DiscoverPhase:Analysis');
const BATCH_ANALYSIS_MIN_FILES = 40;
Expand Down Expand Up @@ -1266,13 +1266,17 @@ export function useAnalysis(options = {}) {
completedCountRef.current = 0;
const fileByPath = new Map(uniqueFiles.map((/** @type {any} */ file) => [file.path, file]));

const runBatchPath = async () => {
// batchFiles: the files this attempt sends; a run that waited out an
// earlier batch sends only what that batch did not finish (see
// batchBusy.js). Progress still counts against the whole run.
const runBatchPath = async (/** @type {any[]} */ batchFiles = uniqueFiles) => {
const alreadyDone = uniqueFiles.length - batchFiles.length;
const batchApi = window?.electronAPI?.analysis?.batch;
if (typeof batchApi !== 'function') {
throw new Error('Batch analysis API not available');
}

for (const file of uniqueFiles) {
for (const file of batchFiles) {
const fileName = file.name || file.path.split(/[\\/]/).pop();
updateFileState(file.path, 'analyzing', { fileName });
}
Expand All @@ -1299,7 +1303,10 @@ export function useAnalysis(options = {}) {
}

const previousCompleted = Number(completedCountRef.current) || 0;
const current = Math.min(Number(payload.completed) || 0, uniqueFiles.length);
const current = Math.min(
alreadyDone + (Number(payload.completed) || 0),
uniqueFiles.length
);
if (current > previousCompleted && typeof payload.currentFile === 'string') {
batchCompletedPathsRef.current.add(payload.currentFile);
}
Expand Down Expand Up @@ -1374,7 +1381,7 @@ export function useAnalysis(options = {}) {
const batchResult = await withTimeout(
batchApi({
batchId: clientBatchId,
filePaths: uniqueFiles.map((/** @type {any} */ file) => file.path),
filePaths: batchFiles.map((/** @type {any} */ file) => file.path),
options: {
concurrency,
autoConcurrency,
Expand All @@ -1384,7 +1391,7 @@ export function useAnalysis(options = {}) {
}
}),
TIMEOUTS.AI_ANALYSIS_BATCH,
`Batch analysis for ${uniqueFiles.length} files`
`Batch analysis for ${batchFiles.length} files`
);

if (!batchResult || !Array.isArray(batchResult.results)) {
Expand Down Expand Up @@ -1458,7 +1465,7 @@ export function useAnalysis(options = {}) {
applyAnalysisOutcome(fileInfo, resultPayload, explicitError);
}

const newCompletedCount = Math.min(i + 1, uniqueFiles.length);
const newCompletedCount = Math.min(alreadyDone + i + 1, uniqueFiles.length);
completedCountRef.current = newCompletedCount;
lastProgressAtRef.current = Date.now();
const progress = {
Expand Down Expand Up @@ -1624,10 +1631,27 @@ export function useAnalysis(options = {}) {
systemRecommended,
lowVramBatch: systemRecommended === 1
});
await runBatchPathWhenFree({
const batchOutcome = await runBatchPathWhenFree({
runBatchPath,
isActiveRun,
abortSignal,
// While waiting, show what the earlier batch finished and send
// only the rest once it is done. The ready queue is durable;
// the analysis cache keeps results only 30 minutes.
selectRemaining: async () => {
await hydrateReadyQueueResults();
const getReadyQueue = window?.electronAPI?.analysis?.getReadyQueue;
if (typeof getReadyQueue !== 'function') return null;
try {
return filesNotYetReady(
uniqueFiles,
normalizeReadyQueuePayload(await getReadyQueue()),
toCanonicalPath
);
} catch {
return null;
}
},
onWaiting: () =>
addNotification(
'Analysis from before is still finishing. Continuing when it is done.',
Expand All @@ -1636,6 +1660,12 @@ export function useAnalysis(options = {}) {
'analysis-batch-busy'
)
});
if (batchOutcome === 'finished-elsewhere') {
// The earlier batch finished every file of this run; its
// results were taken from the ready queue while waiting.
completedCountRef.current = uniqueFiles.length;
lastProgressAtRef.current = Date.now();
}
} catch (batchError) {
const isLegacyBatchTimeout =
String(batchError?.message || '').includes('30000ms') &&
Expand Down
Loading
Loading