Skip to content

Commit bfdd302

Browse files
authored
Merge pull request microsoft#187766 from microsoft/aamunger/notebookOutput
perf optimizations for large stream notebook outputs
2 parents e9af68e + 72806b4 commit bfdd302

File tree

12 files changed

+515
-84
lines changed

12 files changed

+515
-84
lines changed

extensions/notebook-renderers/src/index.ts

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import type { ActivationFunction, OutputItem, RendererContext } from 'vscode-notebook-renderer';
7-
import { createOutputContent, scrollableClass } from './textHelper';
8-
import { HtmlRenderingHook, IDisposable, IRichRenderContext, JavaScriptRenderingHook, RenderOptions } from './rendererTypes';
7+
import { createOutputContent, appendOutput, scrollableClass } from './textHelper';
8+
import { HtmlRenderingHook, IDisposable, IRichRenderContext, JavaScriptRenderingHook, OutputWithAppend, RenderOptions } from './rendererTypes';
99
import { ttPolicy } from './htmlHelper';
1010

1111
function clearContainer(container: HTMLElement) {
@@ -152,7 +152,7 @@ function renderError(
152152
outputInfo: OutputItem,
153153
outputElement: HTMLElement,
154154
ctx: IRichRenderContext,
155-
trustHTML: boolean
155+
trustHtml: boolean
156156
): IDisposable {
157157
const disposableStore = createDisposableStore();
158158

@@ -172,7 +172,7 @@ function renderError(
172172
outputElement.classList.add('traceback');
173173

174174
const outputScrolling = scrollingEnabled(outputInfo, ctx.settings);
175-
const content = createOutputContent(outputInfo.id, [err.stack ?? ''], ctx.settings.lineLimit, outputScrolling, trustHTML);
175+
const content = createOutputContent(outputInfo.id, err.stack ?? '', { linesLimit: ctx.settings.lineLimit, scrollable: outputScrolling, trustHtml });
176176
const contentParent = document.createElement('div');
177177
contentParent.classList.toggle('word-wrap', ctx.settings.outputWordWrap);
178178
disposableStore.push(ctx.onDidChangeSettings(e => {
@@ -270,29 +270,23 @@ function scrollingEnabled(output: OutputItem, options: RenderOptions) {
270270
// div.output.output-stream <-- outputElement parameter
271271
// div.scrollable? tabindex="0" <-- contentParent
272272
// div output-item-id="{guid}" <-- content from outputItem parameter
273-
function renderStream(outputInfo: OutputItem, outputElement: HTMLElement, error: boolean, ctx: IRichRenderContext): IDisposable {
273+
function renderStream(outputInfo: OutputWithAppend, outputElement: HTMLElement, error: boolean, ctx: IRichRenderContext): IDisposable {
274274
const disposableStore = createDisposableStore();
275275
const outputScrolling = scrollingEnabled(outputInfo, ctx.settings);
276+
const outputOptions = { linesLimit: ctx.settings.lineLimit, scrollable: outputScrolling, trustHtml: false, error };
276277

277278
outputElement.classList.add('output-stream');
278279

279-
const text = outputInfo.text();
280-
const newContent = createOutputContent(outputInfo.id, [text], ctx.settings.lineLimit, outputScrolling, false);
281-
newContent.setAttribute('output-item-id', outputInfo.id);
282-
if (error) {
283-
newContent.classList.add('error');
284-
}
285-
286280
const scrollTop = outputScrolling ? findScrolledHeight(outputElement) : undefined;
287281

288282
const previousOutputParent = getPreviousMatchingContentGroup(outputElement);
289283
// If the previous output item for the same cell was also a stream, append this output to the previous
290284
if (previousOutputParent) {
291285
const existingContent = previousOutputParent.querySelector(`[output-item-id="${outputInfo.id}"]`) as HTMLElement | null;
292286
if (existingContent) {
293-
existingContent.replaceWith(newContent);
294-
287+
appendOutput(outputInfo, existingContent, outputOptions);
295288
} else {
289+
const newContent = createOutputContent(outputInfo.id, outputInfo.text(), outputOptions);
296290
previousOutputParent.appendChild(newContent);
297291
}
298292
previousOutputParent.classList.toggle('scrollbar-visible', previousOutputParent.scrollHeight > previousOutputParent.clientHeight);
@@ -301,12 +295,9 @@ function renderStream(outputInfo: OutputItem, outputElement: HTMLElement, error:
301295
const existingContent = outputElement.querySelector(`[output-item-id="${outputInfo.id}"]`) as HTMLElement | null;
302296
let contentParent = existingContent?.parentElement;
303297
if (existingContent && contentParent) {
304-
existingContent.replaceWith(newContent);
305-
while (newContent.nextSibling) {
306-
// clear out any stale content if we had previously combined streaming outputs into this one
307-
newContent.nextSibling.remove();
308-
}
298+
appendOutput(outputInfo, existingContent, outputOptions);
309299
} else {
300+
const newContent = createOutputContent(outputInfo.id, outputInfo.text(), outputOptions);
310301
contentParent = document.createElement('div');
311302
contentParent.appendChild(newContent);
312303
while (outputElement.firstChild) {
@@ -333,7 +324,7 @@ function renderText(outputInfo: OutputItem, outputElement: HTMLElement, ctx: IRi
333324

334325
const text = outputInfo.text();
335326
const outputScrolling = scrollingEnabled(outputInfo, ctx.settings);
336-
const content = createOutputContent(outputInfo.id, [text], ctx.settings.lineLimit, outputScrolling, false);
327+
const content = createOutputContent(outputInfo.id, text, { linesLimit: ctx.settings.lineLimit, scrollable: outputScrolling, trustHtml: false });
337328
content.classList.add('output-plaintext');
338329
if (ctx.settings.outputWordWrap) {
339330
content.classList.add('word-wrap');

extensions/notebook-renderers/src/rendererTypes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,14 @@ export interface RenderOptions {
3535
}
3636

3737
export type IRichRenderContext = RendererContext<void> & { readonly settings: RenderOptions; readonly onDidChangeSettings: Event<RenderOptions> };
38+
39+
export type OutputElementOptions = {
40+
linesLimit: number;
41+
scrollable?: boolean;
42+
error?: boolean;
43+
trustHtml?: boolean;
44+
};
45+
46+
export interface OutputWithAppend extends OutputItem {
47+
appendedText?(): string | undefined;
48+
}

extensions/notebook-renderers/src/test/notebookRenderer.test.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
import * as assert from 'assert';
77
import { activate } from '..';
8-
import { OutputItem, RendererApi } from 'vscode-notebook-renderer';
9-
import { IDisposable, IRichRenderContext, RenderOptions } from '../rendererTypes';
8+
import { RendererApi } from 'vscode-notebook-renderer';
9+
import { IDisposable, IRichRenderContext, OutputWithAppend, RenderOptions } from '../rendererTypes';
1010
import { JSDOM } from "jsdom";
1111

1212
const dom = new JSDOM();
@@ -116,10 +116,13 @@ suite('Notebook builtin output renderer', () => {
116116
}
117117
}
118118

119-
function createOutputItem(text: string, mime: string, id: string = '123'): OutputItem {
119+
function createOutputItem(text: string, mime: string, id: string = '123', appendedText?: string): OutputWithAppend {
120120
return {
121121
id: id,
122122
mime: mime,
123+
appendedText() {
124+
return appendedText;
125+
},
123126
text() {
124127
return text;
125128
},
@@ -177,9 +180,9 @@ suite('Notebook builtin output renderer', () => {
177180
assert.ok(renderer, 'Renderer not created');
178181

179182
const outputElement = new OutputHtml().getFirstOuputElement();
180-
const outputItem = createOutputItem('content', 'text/plain');
183+
const outputItem = createOutputItem('content', mimeType);
181184
await renderer!.renderOutputItem(outputItem, outputElement);
182-
const outputItem2 = createOutputItem('replaced content', 'text/plain');
185+
const outputItem2 = createOutputItem('replaced content', mimeType);
183186
await renderer!.renderOutputItem(outputItem2, outputElement);
184187

185188
const inserted = outputElement.firstChild as HTMLElement;
@@ -189,6 +192,87 @@ suite('Notebook builtin output renderer', () => {
189192

190193
});
191194

195+
test('Append streaming output', async () => {
196+
const context = createContext({ outputWordWrap: false, outputScrolling: true });
197+
const renderer = await activate(context);
198+
assert.ok(renderer, 'Renderer not created');
199+
200+
const outputElement = new OutputHtml().getFirstOuputElement();
201+
const outputItem = createOutputItem('content', stdoutMimeType, '123', 'ignoredAppend');
202+
await renderer!.renderOutputItem(outputItem, outputElement);
203+
const outputItem2 = createOutputItem('content\nappended', stdoutMimeType, '123', '\nappended');
204+
await renderer!.renderOutputItem(outputItem2, outputElement);
205+
206+
const inserted = outputElement.firstChild as HTMLElement;
207+
assert.ok(inserted.innerHTML.indexOf('>content</') !== -1, `Previous content should still exist: ${outputElement.innerHTML}`);
208+
assert.ok(inserted.innerHTML.indexOf('ignoredAppend') === -1, `Append value should not be used on first render: ${outputElement.innerHTML}`);
209+
assert.ok(inserted.innerHTML.indexOf('>appended</') !== -1, `Content was not appended to output element: ${outputElement.innerHTML}`);
210+
assert.ok(inserted.innerHTML.indexOf('>content</') === inserted.innerHTML.lastIndexOf('>content</'), `Original content should not be duplicated: ${outputElement.innerHTML}`);
211+
});
212+
213+
test(`Appending multiple streaming outputs`, async () => {
214+
const context = createContext({ outputScrolling: true });
215+
const renderer = await activate(context);
216+
assert.ok(renderer, 'Renderer not created');
217+
218+
const outputHtml = new OutputHtml();
219+
const firstOutputElement = outputHtml.getFirstOuputElement();
220+
const outputItem1 = createOutputItem('first stream content', stdoutMimeType, '1');
221+
const outputItem2 = createOutputItem(JSON.stringify(error), errorMimeType, '2');
222+
const outputItem3 = createOutputItem('second stream content', stdoutMimeType, '3');
223+
await renderer!.renderOutputItem(outputItem1, firstOutputElement);
224+
const secondOutputElement = outputHtml.appendOutputElement();
225+
await renderer!.renderOutputItem(outputItem2, secondOutputElement);
226+
const thirdOutputElement = outputHtml.appendOutputElement();
227+
await renderer!.renderOutputItem(outputItem3, thirdOutputElement);
228+
229+
const appendedItem1 = createOutputItem('', stdoutMimeType, '1', ' appended1');
230+
await renderer!.renderOutputItem(appendedItem1, firstOutputElement);
231+
const appendedItem3 = createOutputItem('', stdoutMimeType, '3', ' appended3');
232+
await renderer!.renderOutputItem(appendedItem3, thirdOutputElement);
233+
234+
assert.ok(firstOutputElement.innerHTML.indexOf('>first stream content') > -1, `Content was not added to output element: ${outputHtml.cellElement.innerHTML}`);
235+
assert.ok(firstOutputElement.innerHTML.indexOf('appended1') > -1, `Content was not appended to output element: ${outputHtml.cellElement.innerHTML}`);
236+
assert.ok(secondOutputElement.innerHTML.indexOf('>NameError</') > -1, `Content was not added to output element: ${outputHtml.cellElement.innerHTML}`);
237+
assert.ok(thirdOutputElement.innerHTML.indexOf('>second stream content') > -1, `Content was not added to output element: ${outputHtml.cellElement.innerHTML}`);
238+
assert.ok(thirdOutputElement.innerHTML.indexOf('appended3') > -1, `Content was not appended to output element: ${outputHtml.cellElement.innerHTML}`);
239+
});
240+
241+
test('Append large streaming outputs', async () => {
242+
const context = createContext({ outputWordWrap: false, outputScrolling: true });
243+
const renderer = await activate(context);
244+
assert.ok(renderer, 'Renderer not created');
245+
246+
const outputElement = new OutputHtml().getFirstOuputElement();
247+
const lotsOfLines = new Array(4998).fill('line').join('\n');
248+
const firstOuput = lotsOfLines + 'expected1';
249+
const outputItem = createOutputItem(firstOuput, stdoutMimeType, '123');
250+
await renderer!.renderOutputItem(outputItem, outputElement);
251+
const appended = '\n' + lotsOfLines + 'expectedAppend';
252+
const outputItem2 = createOutputItem(firstOuput + appended, stdoutMimeType, '123', appended);
253+
await renderer!.renderOutputItem(outputItem2, outputElement);
254+
255+
const inserted = outputElement.firstChild as HTMLElement;
256+
assert.ok(inserted.innerHTML.indexOf('expected1') !== -1, `Last bit of previous content should still exist`);
257+
assert.ok(inserted.innerHTML.indexOf('expectedAppend') !== -1, `Content was not appended to output element`);
258+
});
259+
260+
test('Streaming outputs larger than the line limit are truncated', async () => {
261+
const context = createContext({ outputWordWrap: false, outputScrolling: true });
262+
const renderer = await activate(context);
263+
assert.ok(renderer, 'Renderer not created');
264+
265+
const outputElement = new OutputHtml().getFirstOuputElement();
266+
const lotsOfLines = new Array(11000).fill('line').join('\n');
267+
const firstOuput = 'shouldBeTruncated' + lotsOfLines + 'expected1';
268+
const outputItem = createOutputItem(firstOuput, stdoutMimeType, '123');
269+
await renderer!.renderOutputItem(outputItem, outputElement);
270+
271+
const inserted = outputElement.firstChild as HTMLElement;
272+
assert.ok(inserted.innerHTML.indexOf('expected1') !== -1, `Last bit of content should exist`);
273+
assert.ok(inserted.innerHTML.indexOf('shouldBeTruncated') === -1, `Beginning content should be truncated`);
274+
});
275+
192276
test(`Render with wordwrap and scrolling for error output`, async () => {
193277
const context = createContext({ outputWordWrap: true, outputScrolling: true });
194278
const renderer = await activate(context);
@@ -268,6 +352,29 @@ suite('Notebook builtin output renderer', () => {
268352
assert.ok(inserted.innerHTML.indexOf('>second stream content</') === -1, `Content was not replaced in output element: ${outputHtml.cellElement.innerHTML}`);
269353
});
270354

355+
test(`Consolidated streaming outputs should append matching outputs correctly`, async () => {
356+
const context = createContext({ outputScrolling: true });
357+
const renderer = await activate(context);
358+
assert.ok(renderer, 'Renderer not created');
359+
360+
const outputHtml = new OutputHtml();
361+
const outputElement = outputHtml.getFirstOuputElement();
362+
const outputItem1 = createOutputItem('first stream content', stdoutMimeType, '1');
363+
const outputItem2 = createOutputItem('second stream content', stdoutMimeType, '2');
364+
await renderer!.renderOutputItem(outputItem1, outputElement);
365+
const secondOutput = outputHtml.appendOutputElement();
366+
await renderer!.renderOutputItem(outputItem2, secondOutput);
367+
const appendingOutput = createOutputItem('', stdoutMimeType, '2', ' appended');
368+
await renderer!.renderOutputItem(appendingOutput, secondOutput);
369+
370+
371+
const inserted = outputElement.firstChild as HTMLElement;
372+
assert.ok(inserted, `nothing appended to output element: ${outputElement.innerHTML}`);
373+
assert.ok(inserted.innerHTML.indexOf('>first stream content</') > -1, `Content was not added to output element: ${outputHtml.cellElement.innerHTML}`);
374+
assert.ok(inserted.innerHTML.indexOf('>second stream content') > -1, `Second content was not added to ouptut element: ${outputHtml.cellElement.innerHTML}`);
375+
assert.ok(inserted.innerHTML.indexOf('appended') > -1, `Content was not appended to ouptut element: ${outputHtml.cellElement.innerHTML}`);
376+
});
377+
271378
test(`Streaming outputs interleaved with other mime types will produce separate outputs`, async () => {
272379
const context = createContext({ outputScrolling: false });
273380
const renderer = await activate(context);

extensions/notebook-renderers/src/textHelper.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { handleANSIOutput } from './ansi';
7-
7+
import { OutputElementOptions, OutputWithAppend } from './rendererTypes';
88
export const scrollableClass = 'scrollable';
99

10+
const softScrollableLineLimit = 5000;
11+
const hardScrollableLineLimit = 8000;
12+
1013
/**
1114
* Output is Truncated. View as a [scrollable element] or open in a [text editor]. Adjust cell output [settings...]
1215
*/
@@ -91,22 +94,70 @@ function truncatedArrayOfString(id: string, buffer: string[], linesLimit: number
9194

9295
function scrollableArrayOfString(id: string, buffer: string[], trustHtml: boolean) {
9396
const element = document.createElement('div');
94-
if (buffer.length > 5000) {
97+
if (buffer.length > softScrollableLineLimit) {
9598
element.appendChild(generateNestedViewAllElement(id));
9699
}
97100

98-
element.appendChild(handleANSIOutput(buffer.slice(-5000).join('\n'), trustHtml));
101+
element.appendChild(handleANSIOutput(buffer.slice(-1 * softScrollableLineLimit).join('\n'), trustHtml));
99102

100103
return element;
101104
}
102105

103-
export function createOutputContent(id: string, outputs: string[], linesLimit: number, scrollable: boolean, trustHtml: boolean): HTMLElement {
106+
const outputLengths: Record<string, number> = {};
104107

105-
const buffer = outputs.join('\n').split(/\r\n|\r|\n/g);
108+
function appendScrollableOutput(element: HTMLElement, id: string, appended: string, trustHtml: boolean) {
109+
if (!outputLengths[id]) {
110+
outputLengths[id] = 0;
111+
}
106112

113+
const buffer = appended.split(/\r\n|\r|\n/g);
114+
const appendedLength = buffer.length + outputLengths[id];
115+
// Only append outputs up to the hard limit of lines, then replace it with the last softLimit number of lines
116+
if (appendedLength > hardScrollableLineLimit) {
117+
return false;
118+
}
119+
else {
120+
element.appendChild(handleANSIOutput(buffer.join('\n'), trustHtml));
121+
outputLengths[id] = appendedLength;
122+
}
123+
return true;
124+
}
125+
126+
export function createOutputContent(id: string, outputText: string, options: OutputElementOptions): HTMLElement {
127+
const { linesLimit, error, scrollable, trustHtml } = options;
128+
const buffer = outputText.split(/\r\n|\r|\n/g);
129+
outputLengths[id] = outputLengths[id] = Math.min(buffer.length, softScrollableLineLimit);
130+
131+
let outputElement: HTMLElement;
107132
if (scrollable) {
108-
return scrollableArrayOfString(id, buffer, trustHtml);
133+
outputElement = scrollableArrayOfString(id, buffer, !!trustHtml);
109134
} else {
110-
return truncatedArrayOfString(id, buffer, linesLimit, trustHtml);
135+
outputElement = truncatedArrayOfString(id, buffer, linesLimit, !!trustHtml);
136+
}
137+
138+
outputElement.setAttribute('output-item-id', id);
139+
if (error) {
140+
outputElement.classList.add('error');
111141
}
142+
143+
return outputElement;
112144
}
145+
146+
export function appendOutput(outputInfo: OutputWithAppend, existingContent: HTMLElement, options: OutputElementOptions) {
147+
const appendedText = outputInfo.appendedText?.();
148+
// appending output only supported for scrollable ouputs currently
149+
if (appendedText && options.scrollable) {
150+
if (appendScrollableOutput(existingContent, outputInfo.id, appendedText, false)) {
151+
return;
152+
}
153+
}
154+
155+
const newContent = createOutputContent(outputInfo.id, outputInfo.text(), options);
156+
existingContent.replaceWith(newContent);
157+
while (newContent.nextSibling) {
158+
// clear out any stale content if we had previously combined streaming outputs into this one
159+
newContent.nextSibling.remove();
160+
}
161+
162+
}
163+

src/vs/workbench/api/common/extHostNotebookDocument.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export class ExtHostCell {
132132
const compressed = notebookCommon.compressOutputItemStreams(mimeOutputs.get(mime)!);
133133
output.items.push({
134134
mime,
135-
data: compressed.buffer
135+
data: compressed.data.buffer
136136
});
137137
});
138138
}

0 commit comments

Comments
 (0)