Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve overlay rendering performance #5156

Merged
merged 9 commits into from
Nov 22, 2024
Merged
11 changes: 5 additions & 6 deletions app/packages/looker/src/elements/common/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,11 @@ export class ErrorElement<State extends BaseState> extends BaseElement<State> {
}
}

if (!error && this.errorElement) {
this.errorElement.remove();
this.errorElement = null;
}

return this.errorElement;
}
}

const onClick = (href) => {
let openExternal;

return null;
};
35 changes: 32 additions & 3 deletions app/packages/looker/src/elements/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,48 @@ import type { ImageState } from "../state";
import type { Events } from "./base";
import { BaseElement } from "./base";

const MAX_IMAGE_LOAD_RETRIES = 10;

export class ImageElement extends BaseElement<ImageState, HTMLImageElement> {
private src = "";
private imageSource: HTMLImageElement;
protected imageSource: HTMLImageElement;

private retryCount = 0;
private timeoutId: number | null = null;

getEvents(): Events<ImageState> {
return {
load: ({ update }) => {
if (this.timeoutId !== null) {
window.clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.retryCount = 0;

this.imageSource = this.element;

update({
loaded: true,
error: false,
dimensions: [this.element.naturalWidth, this.element.naturalHeight],
});
},
error: ({ update }) => {
update({ error: true, dimensions: [512, 512], loaded: true });
// sometimes image loading fails because of insufficient resources
// we'll want to try again in those cases
if (this.retryCount < MAX_IMAGE_LOAD_RETRIES) {
// schedule a retry after a delay
if (this.timeoutId !== null) {
window.clearTimeout(this.timeoutId);
}
this.timeoutId = window.setTimeout(() => {
this.retryCount += 1;
const retrySrc = `${this.src}`;
this.element.setAttribute("src", retrySrc);
// linear backoff
}, 1000 * this.retryCount);
}
},
};
}
Expand All @@ -36,10 +62,13 @@ export class ImageElement extends BaseElement<ImageState, HTMLImageElement> {
renderSelf({ config: { src } }: Readonly<ImageState>) {
if (this.src !== src) {
this.src = src;

this.retryCount = 0;
if (this.timeoutId !== null) {
window.clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.element.setAttribute("src", src);
}

return null;
}
}
80 changes: 80 additions & 0 deletions app/packages/looker/src/worker/decorated-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithLinearBackoff } from "./decorated-fetch";

describe("fetchWithLinearBackoff", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});

it("should return response when fetch succeeds on first try", async () => {
const mockResponse = new Response("Success", { status: 200 });
global.fetch = vi.fn().mockResolvedValue(mockResponse);

const response = await fetchWithLinearBackoff("http://fiftyone.ai");

expect(response).toBe(mockResponse);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith("http://fiftyone.ai");
});

it("should retry when fetch fails and eventually succeed", async () => {
const mockResponse = new Response("Success", { status: 200 });
global.fetch = vi
.fn()
.mockRejectedValueOnce(new Error("Network Error"))
.mockResolvedValue(mockResponse);

const response = await fetchWithLinearBackoff("http://fiftyone.ai");

expect(response).toBe(mockResponse);
expect(global.fetch).toHaveBeenCalledTimes(2);
});

it("should throw an error after max retries when fetch fails every time", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("Network Error"));

await expect(
fetchWithLinearBackoff("http://fiftyone.ai", 3, 10)
).rejects.toThrowError(new RegExp("Max retries for fetch reached"));

expect(global.fetch).toHaveBeenCalledTimes(3);
});
sashankaryal marked this conversation as resolved.
Show resolved Hide resolved

it("should throw an error when response is not ok", async () => {
const mockResponse = new Response("Not Found", { status: 404 });
global.fetch = vi.fn().mockResolvedValue(mockResponse);

await expect(
fetchWithLinearBackoff("http://fiftyone.ai", 5, 10)
).rejects.toThrow("HTTP error: 404");

expect(global.fetch).toHaveBeenCalledTimes(5);
});

it("should apply linear backoff between retries", async () => {
const mockResponse = new Response("Success", { status: 200 });
global.fetch = vi
.fn()
.mockRejectedValueOnce(new Error("Network Error"))
.mockRejectedValueOnce(new Error("Network Error"))
.mockResolvedValue(mockResponse);

vi.useFakeTimers();

const fetchPromise = fetchWithLinearBackoff("http://fiftyone.ai", 5, 10);

// advance timers to simulate delays
// after first delay
await vi.advanceTimersByTimeAsync(100);
// after scond delay
await vi.advanceTimersByTimeAsync(200);

const response = await fetchPromise;

expect(response).toBe(mockResponse);
expect(global.fetch).toHaveBeenCalledTimes(3);

vi.useRealTimers();
});
});
25 changes: 25 additions & 0 deletions app/packages/looker/src/worker/decorated-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const DEFAULT_MAX_RETRIES = 10;
const DEFAULT_BASE_DELAY = 200;

export const fetchWithLinearBackoff = async (
url: string,
retries = DEFAULT_MAX_RETRIES,
delay = DEFAULT_BASE_DELAY
) => {
sashankaryal marked this conversation as resolved.
Show resolved Hide resolved
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
return response;
} catch (e) {
if (i < retries - 1) {
await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)));
} else {
throw new Error(
"Max retries for fetch reached (linear backoff), error: " + e
);
}
}
}
sashankaryal marked this conversation as resolved.
Show resolved Hide resolved
return null;
};
sashankaryal marked this conversation as resolved.
Show resolved Hide resolved
66 changes: 43 additions & 23 deletions app/packages/looker/src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
Sample,
} from "../state";
import { decodeWithCanvas } from "./canvas-decoder";
import { fetchWithLinearBackoff } from "./decorated-fetch";
import { DeserializerFactory } from "./deserializer";
import { PainterFactory } from "./painter";
import { mapId } from "./shared";
Expand Down Expand Up @@ -107,11 +108,12 @@ const imputeOverlayFromPath = async (
colorscale: Colorscale,
buffers: ArrayBuffer[],
sources: { [path: string]: string },
cls: string
cls: string,
maskPathDecodingPromises: Promise<void>[] = []
) => {
// handle all list types here
if (cls === DETECTIONS) {
const promises = [];
const promises: Promise<void>[] = [];
for (const detection of label.detections) {
promises.push(
imputeOverlayFromPath(
Expand All @@ -126,10 +128,7 @@ const imputeOverlayFromPath = async (
)
);
}
// if some paths fail to load, it's okay, we can still proceed
// hence we use `allSettled` instead of `all`
await Promise.allSettled(promises);
return;
maskPathDecodingPromises.push(...promises);
}

// overlay path is in `map_path` property for heatmap, or else, it's in `mask_path` property (for segmentation or detection)
Expand Down Expand Up @@ -157,14 +156,17 @@ const imputeOverlayFromPath = async (
baseUrl = overlayImageUrl.split("?")[0];
}

const overlayImageBuffer: Blob = await getFetchFunction()(
"GET",
overlayImageUrl,
null,
"blob"
);
let overlayImageBlob: Blob;
try {
const overlayImageFetchResponse = await fetchWithLinearBackoff(baseUrl);
overlayImageBlob = await overlayImageFetchResponse.blob();
} catch (e) {
console.error(e);
// skip decoding if fetch fails altogether
return;
}

const overlayMask = await decodeWithCanvas(overlayImageBuffer);
const overlayMask = await decodeWithCanvas(overlayImageBlob);
const [overlayHeight, overlayWidth] = overlayMask.shape;

// set the `mask` property for this label
Expand All @@ -190,8 +192,11 @@ const processLabels = async (
schema: Schema
): Promise<ArrayBuffer[]> => {
const buffers: ArrayBuffer[] = [];
const promises = [];
const painterPromises = [];

const maskPathDecodingPromises = [];

// mask deserialization / mask_path decoding loop
for (const field in sample) {
let labels = sample[field];
if (!Array.isArray(labels)) {
Expand All @@ -205,20 +210,19 @@ const processLabels = async (
}

if (DENSE_LABELS.has(cls)) {
try {
await imputeOverlayFromPath(
maskPathDecodingPromises.push(
imputeOverlayFromPath(
`${prefix || ""}${field}`,
label,
coloring,
customizeColorSetting,
colorscale,
buffers,
sources,
cls
);
} catch (e) {
console.error("Couldn't decode overlay image from disk: ", e);
}
cls,
maskPathDecodingPromises
)
);
}

if (cls in DeserializerFactory) {
Expand Down Expand Up @@ -249,9 +253,25 @@ const processLabels = async (
mapId(label);
}
}
}
}

await Promise.allSettled(maskPathDecodingPromises);

// overlay painting loop
for (const field in sample) {
let labels = sample[field];
if (!Array.isArray(labels)) {
labels = [labels];
}
const cls = getCls(`${prefix ? prefix : ""}${field}`, schema);

for (const label of labels) {
if (!label) {
continue;
}
if (painterFactory[cls]) {
promises.push(
painterPromises.push(
painterFactory[cls](
prefix ? prefix + field : field,
label,
Expand All @@ -266,7 +286,7 @@ const processLabels = async (
}
}

return Promise.all(promises).then(() => buffers);
return Promise.all(painterPromises).then(() => buffers);
};

/** GLOBALS */
Expand Down
4 changes: 2 additions & 2 deletions app/packages/utilities/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface FetchFunction {
body?: A,
result?: "json" | "blob" | "text" | "arrayBuffer" | "json-stream",
retries?: number,
retryCodes?: number[] | "arrayBuffer"
retryCodes?: number[]
): Promise<R>;
}

Expand Down Expand Up @@ -110,7 +110,7 @@ export const setFetchFunction = (
const fetchCall = retries
? fetchRetry(fetch, {
retries,
retryDelay: 0,
retryDelay: 500,
retryOn: (attempt, error, response) => {
if (
(error !== null || retryCodes.includes(response.status)) &&
Expand Down
Loading