Skip to content

Commit 084e92f

Browse files
committed
Implement a concurrency cap in loading GNATcoverage report
1 parent bf9e917 commit 084e92f

File tree

3 files changed

+346
-9
lines changed

3 files changed

+346
-9
lines changed

integration/vscode/ada/src/gnatcov.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import * as fs from 'fs';
33
import * as path from 'path';
44
import * as vscode from 'vscode';
55
import { CancellationToken } from 'vscode-languageclient';
6+
import { parallelize, staggerProgress } from './helpers';
7+
import { cpus } from 'os';
68

79
/**
810
* TypeScript types to represent data from GNATcoverage XML reports
@@ -291,10 +293,16 @@ export async function addCoverageData(run: vscode.TestRun, covDir: string) {
291293
async (progress, token) => {
292294
const array = data.coverage_report.coverage_summary!.file;
293295
let done: number = 0;
296+
let lastProgress = 0;
294297
const totalFiles = array.length;
298+
progress.report({
299+
message: `${done} / ${totalFiles} source files`,
300+
});
295301
const fileCovs = (
296-
await Promise.all(
297-
array.map(async (file) => {
302+
await parallelize(
303+
array,
304+
Math.min(cpus().length, 8),
305+
async (file) => {
298306
if (token.isCancellationRequested) {
299307
throw new vscode.CancellationError();
300308
}
@@ -333,13 +341,46 @@ export async function addCoverageData(run: vscode.TestRun, covDir: string) {
333341
total,
334342
});
335343

336-
progress.report({
337-
message: `${++done} / ${totalFiles} source files`,
338-
increment: (100 * 1) / totalFiles,
339-
});
344+
/**
345+
* Do we need a lock to increment this counter given
346+
* that we are processing with threads?
347+
*
348+
* The answer is no.
349+
*
350+
* In JavaScript semantics each function runs to
351+
* completion uninterrupted. 'async' doesn't mean that
352+
* functions run concurrently. It means that something
353+
* will be executed later.
354+
*
355+
* When a function flow encounters 'await', it hands
356+
* off processing to another async operation.
357+
*
358+
* So at any one time, only one function is executing
359+
* and accessing the counter. It is safe to increment
360+
* without locking.
361+
*/
362+
++done;
363+
364+
/**
365+
* Reporting progress too often can cause the UI to freeze.
366+
* Instead we use staggerProgress to report progress only
367+
* when the percentage has increased by 1%.
368+
*/
369+
lastProgress = staggerProgress(
370+
done,
371+
totalFiles,
372+
lastProgress,
373+
(increment) => {
374+
progress.report({
375+
message: `${done} / ${totalFiles} source files`,
376+
increment: increment,
377+
});
378+
},
379+
);
340380

341381
return fileCov;
342-
}),
382+
},
383+
token,
343384
)
344385
).filter((v) => !!v);
345386
fileCovs.map((fileCov) => {

integration/vscode/ada/src/helpers.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,89 @@ export async function showErrorMessageWithOpenLogButton(
520520
mainOutputChannel.show();
521521
}
522522
}
523+
524+
/**
525+
*
526+
* @param data - input data to process
527+
* @param threads - number of async functions processing the data. Must be strictly positive 0.
528+
* @param op - the function to process the data
529+
* @returns the processed
530+
*/
531+
export async function parallelize<T, Result>(
532+
data: T[],
533+
threads: number,
534+
op: (arg: T) => Result | Promise<Result>,
535+
token?: CancellationToken,
536+
): Promise<Result[]> {
537+
assert(threads > 0);
538+
const len = data.length;
539+
let start = 0;
540+
541+
/**
542+
* Initialize an array with the expected length.
543+
*/
544+
const result = new Array<Result>(len);
545+
while (start < len) {
546+
if (token?.isCancellationRequested) {
547+
throw new CancellationError();
548+
}
549+
550+
/**
551+
* Take a chunk corresponding to the number of threads.
552+
*/
553+
const chunk = data.slice(start, start + threads);
554+
555+
/**
556+
* Process the chunk as async promises which may be evaluated concurrently.
557+
*/
558+
const chunkRes = await Promise.all(chunk.map(op));
559+
560+
/**
561+
* Since the array is initialized with a length, its content is already
562+
* allocated with empty values. So we need a splice operation where we
563+
* delete the existing empty values and replace them with the results.
564+
*/
565+
result.splice(start, chunkRes.length, ...chunkRes);
566+
567+
start += threads;
568+
}
569+
570+
return result;
571+
}
572+
573+
/**
574+
* When using {@link vscode.window.withProgress}, reporting progress too often
575+
* can cause the UI to freeze. This function helps stagger progress reports
576+
* such that progress is reported only when a minimum increment is reached,
577+
* e.g. every 1%.
578+
*
579+
* Here's how the function should be used:
580+
*
581+
* TODO
582+
*
583+
*
584+
* @param done - the number of items processed since the beginning of the work
585+
* @param total - the total number of items to be processed
586+
* @param lastProgress - the last 'done' value at which progress was reported
587+
* @param progress - a callback that is called when the minimum increment is reached
588+
* @param minimumIncrement - the minimum increment to wait for before calling
589+
* the 'progress' callback.
590+
* @returns `lastProgress` if no progress was reported, and returns `done` when
591+
* progress was reported.
592+
*/
593+
export function staggerProgress(
594+
done: number,
595+
total: number,
596+
lastProgress: number,
597+
progress: (increment: number) => void,
598+
minimumIncrement: number = 1,
599+
): number {
600+
const sinceLastReport = done - lastProgress;
601+
const increment = Math.floor((sinceLastReport * 100) / total);
602+
if (increment >= minimumIncrement) {
603+
progress(increment);
604+
return done;
605+
} else {
606+
return lastProgress;
607+
}
608+
}

integration/vscode/ada/test/general/helpers.test.ts

Lines changed: 212 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import assert from 'assert';
2-
import { envHasExec, findAdaMain, getSymbols, which } from '../../src/helpers';
3-
import { DocumentSymbol, SymbolKind, Uri, commands, workspace } from 'vscode';
2+
import {
3+
envHasExec,
4+
findAdaMain,
5+
getSymbols,
6+
parallelize,
7+
staggerProgress,
8+
which,
9+
} from '../../src/helpers';
10+
import {
11+
CancellationError,
12+
DocumentSymbol,
13+
ProgressLocation,
14+
SymbolKind,
15+
Uri,
16+
commands,
17+
window,
18+
workspace,
19+
} from 'vscode';
420
import { rangeToStr } from '../utils';
521
import { adaExtState } from '../../src/extension';
622

@@ -134,3 +150,197 @@ suite('getSymbols', function () {
134150
};
135151
}
136152
});
153+
154+
suite('parallelize', function () {
155+
const cases = [
156+
{
157+
size: 4,
158+
threads: 1,
159+
workDuration: 200,
160+
},
161+
{
162+
size: 4,
163+
threads: 2,
164+
workDuration: 200,
165+
},
166+
{
167+
size: 4,
168+
threads: 3,
169+
workDuration: 200,
170+
},
171+
{
172+
size: 4,
173+
threads: 4,
174+
workDuration: 200,
175+
},
176+
{
177+
size: 4,
178+
threads: 5,
179+
workDuration: 200,
180+
},
181+
{
182+
size: 2 ** 10,
183+
threads: 3,
184+
workDuration: 0,
185+
},
186+
{
187+
size: 2 ** 20,
188+
threads: 3,
189+
workDuration: 0,
190+
},
191+
];
192+
193+
for (const tc of cases) {
194+
testCase(tc.size, tc.threads, tc.workDuration);
195+
}
196+
197+
function testCase(dataSize: number, threads: number, singleWorkDurationMs: number) {
198+
test(`Process ${dataSize} elements with ${threads} threads`, async function () {
199+
const data: number[] = Array.from({ length: dataSize }, (_, i) => i);
200+
let done = 0;
201+
let lastProgressDone = 0;
202+
const start = Date.now();
203+
const result = await window.withProgress(
204+
{
205+
location: ProgressLocation.Notification,
206+
cancellable: true,
207+
title: this.currentTest?.fullTitle(),
208+
},
209+
async (progress, token) => {
210+
return await parallelize(
211+
data,
212+
threads,
213+
async (i) => {
214+
if (token.isCancellationRequested) {
215+
throw new CancellationError();
216+
}
217+
218+
/**
219+
* Do we need a lock to increment this counter given
220+
* that we are processing with threads?
221+
*
222+
* The answer is no.
223+
*
224+
* In JavaScript semantics each function runs to
225+
* completion uninterrupted. 'async' doesn't mean that
226+
* functions run concurrently. It means that something
227+
* will be executed later.
228+
*
229+
* When a function flow encounters 'await', it hands
230+
* off processing to another async operation.
231+
*
232+
* So at any one time, only one function is executing
233+
* and accessing the counter. It is safe to increment
234+
* without locking.
235+
*/
236+
++done;
237+
238+
const reportEveryProgress = false;
239+
if (reportEveryProgress) {
240+
progress.report({
241+
message: `${done} / ${data.length}`,
242+
increment: (1 * 100) / data.length,
243+
});
244+
} else {
245+
lastProgressDone = staggerProgress(
246+
done,
247+
data.length,
248+
lastProgressDone,
249+
(increment) => {
250+
progress.report({
251+
message: `${done} / ${data.length}`,
252+
increment: increment,
253+
});
254+
},
255+
);
256+
}
257+
258+
/**
259+
* Short-circuit the timing emulation if the test case doesn't want it.
260+
*/
261+
if (singleWorkDurationMs > 0) {
262+
/**
263+
* Create artificial delay in the processing of each item.
264+
*/
265+
await new Promise((resolve) => {
266+
setTimeout(resolve, singleWorkDurationMs);
267+
});
268+
}
269+
270+
return i + 1;
271+
},
272+
token,
273+
);
274+
},
275+
);
276+
const duration = Date.now() - start;
277+
278+
assert.equal(result.length, data.length);
279+
280+
/**
281+
* Don't check timing when the test cases doesn't use timing emulation.
282+
*/
283+
if (singleWorkDurationMs > 0) {
284+
/**
285+
* Given the number of threads, the overal duration should
286+
* not go beyond a certain limit. For example, if the data
287+
* size is 4, and we requested 4 threads to do the
288+
* operation, then all the data is processed in one go. So
289+
* overall the computation shouldn't take more than the
290+
* time needed to process one data item (with a margin).
291+
*/
292+
const margin = singleWorkDurationMs * 0.2;
293+
/**
294+
* When processing 4 items with 3 threads, the first parallel
295+
* batch processes 3 items and a second batch processes 1 item.
296+
* So there are 2 sequential iterations, hence the use of
297+
* Math.ceil.
298+
*/
299+
const sequentialChunks = Math.ceil(dataSize / threads);
300+
const expectedTotalWorkDuration = sequentialChunks * singleWorkDurationMs + margin;
301+
302+
assert.ok(
303+
duration < expectedTotalWorkDuration,
304+
`The computation took ${duration}ms when we expected` +
305+
` no more than ${expectedTotalWorkDuration}`,
306+
);
307+
}
308+
});
309+
}
310+
});
311+
312+
suite('staggerProgress', function () {
313+
test('staggerProgress', function () {
314+
const total = 1000;
315+
let lastProgress = 0;
316+
317+
lastProgress = staggerProgress(5, total, lastProgress, () => {
318+
/**
319+
* progress is less than 1% so this shouldn't be called.
320+
*/
321+
assert.fail('Progress reporting was called unexpectedly');
322+
});
323+
assert.equal(lastProgress, 0);
324+
325+
let called = false;
326+
lastProgress = staggerProgress(10, total, lastProgress, (increment) => {
327+
/**
328+
* Progress is 1%. Reporting should be called.
329+
*/
330+
called = true;
331+
assert.equal(increment, 1);
332+
});
333+
assert.ok(called, 'Progress reporting function was unexpectedly not called');
334+
assert.equal(lastProgress, 10);
335+
336+
lastProgress = staggerProgress(50, total, lastProgress, (increment) => {
337+
/**
338+
* Progress is 1%. Reporting should be called.
339+
*/
340+
called = true;
341+
assert.equal(increment, 4);
342+
});
343+
assert.ok(called, 'Progress reporting function was unexpectedly not called');
344+
assert.equal(lastProgress, 50);
345+
});
346+
});

0 commit comments

Comments
 (0)