webgpu-profiler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Patches `GPUDevice.createBuffer` and `GPUDevice.createTexture` to track
3
+ * every GPU resource the page allocates. Combined with a `.destroy()` patch
4
+ * on each returned object, this gives a live view of all WebGPU memory
5
+ * the JS API can see.
6
+ *
7
+ * Why monkey-patch the device:
8
+ * - WebGPU has *exactly two* JS-visible allocation entry points
9
+ * (`createBuffer`, `createTexture`). Patching both captures everything
10
+ * a renderer, post-processing nodes, and compute services allocate.
11
+ * - No need to instrument production modules. This is the same pattern
12
+ * used by React DevTools, Vue DevTools, and Chrome's GPU inspector.
13
+ *
14
+ * What is NOT captured (driver-internal, not exposed to JS):
15
+ * - Canvas swapchain backbuffer (created by `context.configure()`).
16
+ * - Driver-side staging buffers, residency systems, MSAA backing,
17
+ * pipeline layouts, bind group memory.
18
+ *
19
+ * ## Timing
20
+ *
21
+ * Call `instrumentDevice(device)` *after* the renderer has obtained a
22
+ * `GPUDevice`, but *before* any other code allocates resources. In
23
+ * practice: right after the renderer's `init()` promise resolves. A few
24
+ * allocations (the swapchain, a couple of renderer internals) happen
25
+ * during init itself and will not be captured. Typical overhead: <5 MB.
26
+ *
27
+ * ## Usage
28
+ *
29
+ * import { instrumentDevice } from "webgpu-profiler";
30
+ *
31
+ * const inst = instrumentDevice(device);
32
+ * // ... time passes ...
33
+ * console.log(`GPU bytes: ${inst.bytes()}`);
34
+ * // When done:
35
+ * inst.uninstrument();
36
+ *
37
+ * The returned maps are live views — iterate at query time, do not cache.
38
+ * Entries are removed automatically when `buffer.destroy()` or
39
+ * `texture.destroy()` is called.
40
+ */
41
+
42
+ export interface TrackedBuffer {
43
+ buffer: GPUBuffer;
44
+ descriptor: GPUBufferDescriptor;
45
+ }
46
+
47
+ export interface TrackedTexture {
48
+ texture: GPUTexture;
49
+ descriptor: GPUTextureDescriptor;
50
+ }
51
+
52
+ export interface DeviceInstrumentation {
53
+ readonly device: GPUDevice;
54
+ readonly buffers: ReadonlyMap<GPUBuffer, TrackedBuffer>;
55
+ readonly textures: ReadonlyMap<GPUTexture, TrackedTexture>;
56
+ /** Sum of all tracked buffer sizes, in bytes. */
57
+ bufferBytes(): number;
58
+ /** Sum of all tracked texture sizes (across formats, mip chains, layers). */
59
+ textureBytes(): number;
60
+ /** Combined total in bytes. */
61
+ bytes(): number;
62
+ /** Remove patches and restore the original device methods. */
63
+ uninstrument(): void;
64
+ }
65
+
66
+ // ── GPUTextureFormat → bytes per texel ──────────────────────────────────────
67
+ //
68
+ // Comprehensive table per WebGPU spec. For compressed block formats, bpp is
69
+ // the effective rate (BC1 = 0.5, BC7 = 1.0, etc.). A few approximate cases
70
+ // (depth24plus, ASTC variants) are flagged inline.
71
+
72
+ const FORMAT_BPP: Record<string, number> = {
73
+ // 8-bit single channel
74
+ r8unorm: 1, r8snorm: 1, r8uint: 1, r8sint: 1,
75
+ // 8-bit two channel
76
+ rg8unorm: 2, rg8snorm: 2, rg8uint: 2, rg8sint: 2,
77
+ // 8-bit four channel
78
+ rgba8unorm: 4, "rgba8unorm-srgb": 4,
79
+ rgba8snorm: 4, rgba8uint: 4, rgba8sint: 4,
80
+ bgra8unorm: 4, "bgra8unorm-srgb": 4,
81
+ // 16-bit single channel
82
+ r16float: 2, r16uint: 2, r16sint: 2, r16unorm: 2, r16snorm: 2,
83
+ // 16-bit two channel
84
+ rg16float: 4, rg16uint: 4, rg16sint: 4, rg16unorm: 4, rg16snorm: 4,
85
+ // 16-bit four channel
86
+ rgba16float: 8, rgba16uint: 8, rgba16sint: 8,
87
+ rgba16unorm: 8, rgba16snorm: 8,
88
+ // 32-bit single channel
89
+ r32float: 4, r32uint: 4, r32sint: 4,
90
+ // 32-bit two channel
91
+ rg32float: 8, rg32uint: 8, rg32sint: 8,
92
+ // 32-bit four channel
93
+ rgba32float: 16, rgba32uint: 16, rgba32sint: 16,
94
+ // Packed
95
+ rgb9e5ufloat: 4,
96
+ rgb10a2unorm: 4, rgb10a2uint: 4,
97
+ rg11b10ufloat: 4,
98
+ // Depth / stencil
99
+ stencil8: 1,
100
+ depth16unorm: 2,
101
+ depth24plus: 4, // implementation-defined, conservative
102
+ "depth24plus-stencil8": 4,
103
+ depth32float: 4,
104
+ "depth32float-stencil8": 5,
105
+ // Block-compressed (effective bytes per pixel)
106
+ "bc1-rgba-unorm": 0.5, "bc1-rgba-unorm-srgb": 0.5,
107
+ "bc2-rgba-unorm": 1, "bc2-rgba-unorm-srgb": 1,
108
+ "bc3-rgba-unorm": 1, "bc3-rgba-unorm-srgb": 1,
109
+ "bc4-r-unorm": 0.5, "bc4-r-snorm": 0.5,
110
+ "bc5-rg-unorm": 1, "bc5-rg-snorm": 1,
111
+ "bc6h-rgb-ufloat": 1, "bc6h-rgb-float": 1,
112
+ "bc7-rgba-unorm": 1, "bc7-rgba-unorm-srgb": 1,
113
+ "etc2-rgb8unorm": 0.5, "etc2-rgb8unorm-srgb": 0.5,
114
+ "etc2-rgb8a1unorm": 0.5, "etc2-rgb8a1unorm-srgb": 0.5,
115
+ "etc2-rgba8unorm": 1, "etc2-rgba8unorm-srgb": 1,
116
+ "eac-r11unorm": 0.5, "eac-r11snorm": 0.5,
117
+ "eac-rg11unorm": 1, "eac-rg11snorm": 1,
118
+ // ASTC variants are derived dynamically from the format string — see
119
+ // bytesPerTexel(). All ASTC blocks are 16 bytes; bpp is 16 / (W * H).
120
+ };
121
+
122
+ // ASTC block-compressed formats name their block dimensions inline, e.g.
123
+ // `astc-4x4-unorm` or `astc-12x12-unorm-srgb`. Every block is 16 bytes,
124
+ // covering W * H texels, so bpp = 16 / (W * H). Covers all 28 variants.
125
+ const ASTC_RE = /^astc-(\d+)x(\d+)-/;
126
+
127
+ function bytesPerTexel(format: string): number {
128
+ const known = FORMAT_BPP[format];
129
+ if (known !== undefined) return known;
130
+ const astc = ASTC_RE.exec(format);
131
+ if (astc) {
132
+ const blockW = Number(astc[1]);
133
+ const blockH = Number(astc[2]);
134
+ return 16 / (blockW * blockH);
135
+ }
136
+ return 4;
137
+ }
138
+
139
+ /**
140
+ * Resolve a GPUExtent3D to (w, h, d). Per WebGPU spec, `size` is either a
141
+ * `GPUExtent3DDict` (object with `.width`) or any iterable of numbers
142
+ * (Array, Uint32Array, etc). `Array.isArray` returns false for typed
143
+ * arrays, so we discriminate on the dict's `width` property instead.
144
+ */
145
+ export function resolveExtent(
146
+ size: GPUExtent3D,
147
+ ): { width: number; height: number; depth: number } {
148
+ if (size != null && typeof (size as GPUExtent3DDict).width === "number") {
149
+ const dict = size as GPUExtent3DDict;
150
+ return {
151
+ width: dict.width,
152
+ height: dict.height ?? 1,
153
+ depth: dict.depthOrArrayLayers ?? 1,
154
+ };
155
+ }
156
+ const [width = 0, height = 1, depth = 1] = Array.from(size as Iterable<number>);
157
+ return { width, height, depth };
158
+ }
159
+
160
+ /**
161
+ * Bytes occupied on the GPU by a texture with the given descriptor.
162
+ * Accounts for mip chain, array layers / depth, MSAA samples, and the
163
+ * format-specific bits per pixel.
164
+ *
165
+ * For 3D textures, each mip level halves all three dimensions, so the
166
+ * per-level pixel count series is `8^-i`. For 2D and 2D-array textures
167
+ * only x/y halve, giving `4^-i`.
168
+ */
169
+ export function textureDescriptorBytes(desc: GPUTextureDescriptor): number {
170
+ const bpp = bytesPerTexel(desc.format);
171
+ const { width, height, depth } = resolveExtent(desc.size);
172
+
173
+ const is3D = desc.dimension === "3d";
174
+ const mipLevels = desc.mipLevelCount ?? 1;
175
+ const mipBase = is3D ? 8 : 4;
176
+ let mipFactor = 0;
177
+ for (let i = 0; i < mipLevels; i++) mipFactor += Math.pow(mipBase, -i);
178
+
179
+ const sampleCount = desc.sampleCount ?? 1;
180
+
181
+ return Math.ceil(width * height * depth * bpp * mipFactor * sampleCount);
182
+ }
183
+
184
+ // ── Module-level active instrumentation ─────────────────────────────────────
185
+ //
186
+ // For ease of use, `instrumentDevice` sets the returned handle as the
187
+ // "active" instrumentation. The React `MemoryHUD` reads from this state by
188
+ // default, so callers don't need to thread the handle through props or
189
+ // React context. Power users who want to manage multiple instrumentations
190
+ // can still ignore the global and pass handles explicitly.
191
+
192
+ let active: DeviceInstrumentation | null = null;
193
+ const subscribers = new Set<() => void>();
194
+
195
+ const INSTRUMENTED = Symbol.for("webgpu-profiler.instrumented");
196
+
197
+ function notify(): void {
198
+ for (const cb of subscribers) cb();
199
+ }
200
+
201
+ /**
202
+ * Returns the most recently registered active instrumentation (or `null`
203
+ * if none). For React, prefer using `MemoryHUD` without a prop — it reads
204
+ * this via `useSyncExternalStore` and re-renders when it changes.
205
+ */
206
+ export function getActiveInstrumentation(): DeviceInstrumentation | null {
207
+ return active;
208
+ }
209
+
210
+ /**
211
+ * Subscribe to changes in the active instrumentation. Returns an
212
+ * unsubscribe function. Used internally by `MemoryHUD` via React's
213
+ * `useSyncExternalStore`. Exposed for non-React consumers who want to
214
+ * react to active-instrumentation changes.
215
+ */
216
+ export function subscribeActiveInstrumentation(cb: () => void): () => void {
217
+ subscribers.add(cb);
218
+ return () => {
219
+ subscribers.delete(cb);
220
+ };
221
+ }
222
+
223
+ // ── Patcher ─────────────────────────────────────────────────────────────────
224
+
225
+ export function instrumentDevice(device: GPUDevice): DeviceInstrumentation {
226
+ // Idempotent: calling twice on the same device returns the existing
227
+ // handle rather than double-patching (and double-counting).
228
+ const existing = (device as unknown as { [INSTRUMENTED]?: DeviceInstrumentation })[
229
+ INSTRUMENTED
230
+ ];
231
+ if (existing) {
232
+ active = existing;
233
+ notify();
234
+ return existing;
235
+ }
236
+
237
+ const buffers = new Map<GPUBuffer, TrackedBuffer>();
238
+ const textures = new Map<GPUTexture, TrackedTexture>();
239
+ // Set to false by `uninstrument()`. Guards against a tracking write
240
+ // landing after teardown (rare, but possible if a `createBuffer` call
241
+ // is in-flight while uninstrument runs).
242
+ let alive = true;
243
+
244
+ const origCreateBuffer = device.createBuffer.bind(device);
245
+ const origCreateTexture = device.createTexture.bind(device);
246
+
247
+ device.createBuffer = (descriptor: GPUBufferDescriptor): GPUBuffer => {
248
+ const buffer = origCreateBuffer(descriptor);
249
+ if (alive) buffers.set(buffer, { buffer, descriptor });
250
+ const origDestroy = buffer.destroy.bind(buffer);
251
+ // GPUBuffer.destroy may be called multiple times; the patch handles
252
+ // that idempotently via the no-op Map.delete.
253
+ buffer.destroy = () => {
254
+ buffers.delete(buffer);
255
+ origDestroy();
256
+ };
257
+ return buffer;
258
+ };
259
+
260
+ device.createTexture = (descriptor: GPUTextureDescriptor): GPUTexture => {
261
+ const texture = origCreateTexture(descriptor);
262
+ if (alive) textures.set(texture, { texture, descriptor });
263
+ const origDestroy = texture.destroy.bind(texture);
264
+ texture.destroy = () => {
265
+ textures.delete(texture);
266
+ origDestroy();
267
+ };
268
+ return texture;
269
+ };
270
+
271
+ const inst: DeviceInstrumentation = {
272
+ device,
273
+ buffers,
274
+ textures,
275
+ bufferBytes() {
276
+ let total = 0;
277
+ for (const { descriptor } of buffers.values()) total += descriptor.size;
278
+ return total;
279
+ },
280
+ textureBytes() {
281
+ let total = 0;
282
+ for (const { descriptor } of textures.values()) {
283
+ total += textureDescriptorBytes(descriptor);
284
+ }
285
+ return total;
286
+ },
287
+ bytes() {
288
+ return this.bufferBytes() + this.textureBytes();
289
+ },
290
+ uninstrument() {
291
+ alive = false;
292
+ device.createBuffer = origCreateBuffer;
293
+ device.createTexture = origCreateTexture;
294
+ buffers.clear();
295
+ textures.clear();
296
+ delete (device as unknown as { [INSTRUMENTED]?: DeviceInstrumentation })[
297
+ INSTRUMENTED
298
+ ];
299
+ if (active === inst) {
300
+ active = null;
301
+ notify();
302
+ }
303
+ },
304
+ };
305
+
306
+ (device as unknown as { [INSTRUMENTED]?: DeviceInstrumentation })[INSTRUMENTED] =
307
+ inst;
308
+ active = inst;
309
+ notify();
310
+ return inst;
311
+ }
312
+
313
+ // ── Auto-instrument ─────────────────────────────────────────────────────────
314
+ //
315
+ // Patches `GPUAdapter.prototype.requestDevice` so every device any code
316
+ // requests is automatically instrumented as soon as it's created. Call
317
+ // this once at boot — before any renderer or framework's `init()` runs —
318
+ // and you're done. The React `MemoryHUD` picks up the active
319
+ // instrumentation through `useSyncExternalStore`.
320
+ //
321
+ // Idempotent and safe to call multiple times. No-op if WebGPU is not
322
+ // available in the current environment (e.g. SSR, older browsers).
323
+
324
+ const ADAPTER_PATCHED = Symbol.for("webgpu-profiler.adapter-patched");
325
+
326
+ export interface AutoInstrumentOptions {
327
+ /** Called for each device the patch instruments. Useful for logging. */
328
+ onInstrument?: (instrumentation: DeviceInstrumentation) => void;
329
+ }
330
+
331
+ /**
332
+ * Patches `GPUAdapter.prototype.requestDevice` to auto-instrument every
333
+ * `GPUDevice` it returns. Returns a disposer that restores the original
334
+ * method (existing instrumentations stay live until their own
335
+ * `uninstrument()` is called).
336
+ *
337
+ * Typical usage at the top of your app boot file:
338
+ *
339
+ * import { autoInstrument } from "webgpu-profiler";
340
+ * if (import.meta.env.DEV) autoInstrument();
341
+ *
342
+ * No further wiring needed — render `<MemoryHUD />` anywhere.
343
+ */
344
+ export function autoInstrument(options: AutoInstrumentOptions = {}): () => void {
345
+ if (typeof GPUAdapter === "undefined") {
346
+ // WebGPU not available in this environment; nothing to patch.
347
+ return () => undefined;
348
+ }
349
+ const proto = GPUAdapter.prototype as unknown as {
350
+ [ADAPTER_PATCHED]?: boolean;
351
+ requestDevice: GPUAdapter["requestDevice"];
352
+ };
353
+ if (proto[ADAPTER_PATCHED]) {
354
+ return () => undefined;
355
+ }
356
+
357
+ const orig = proto.requestDevice;
358
+ proto.requestDevice = async function (
359
+ this: GPUAdapter,
360
+ descriptor?: GPUDeviceDescriptor,
361
+ ): Promise<GPUDevice> {
362
+ const device = await orig.call(this, descriptor);
363
+ if (device) {
364
+ const inst = instrumentDevice(device);
365
+ options.onInstrument?.(inst);
366
+ }
367
+ return device;
368
+ } as GPUAdapter["requestDevice"];
369
+ proto[ADAPTER_PATCHED] = true;
370
+
371
+ return () => {
372
+ if (proto[ADAPTER_PATCHED]) {
373
+ proto.requestDevice = orig;
374
+ delete proto[ADAPTER_PATCHED];
375
+ }
376
+ };
377
+ }
@@ -0,0 +1,171 @@
1
+ import {
2
+ resolveExtent,
3
+ textureDescriptorBytes,
4
+ type DeviceInstrumentation,
5
+ } from "./instrument.js";
6
+
7
+ /**
8
+ * Builds a snapshot of GPU memory currently allocated by the page, sourced
9
+ * from a {@link DeviceInstrumentation} handle (see `instrument.ts`).
10
+ *
11
+ * The numbers below are exact for every resource that flows through
12
+ * `GPUDevice.createBuffer` / `createTexture` — those are the only two
13
+ * WebGPU JS-side allocation entry points, so this is exhaustive by
14
+ * construction.
15
+ *
16
+ * Not counted (driver-internal, opaque to JS):
17
+ * - Canvas swapchain backbuffer (created by `context.configure()`).
18
+ * - Residency caches, MSAA backing, pipeline objects, bind group
19
+ * layouts, staging buffers that bypass `createBuffer`.
20
+ */
21
+
22
+ export interface TextureEntry {
23
+ name: string;
24
+ width: number;
25
+ height: number;
26
+ depth: number;
27
+ format: string;
28
+ mipLevels: number;
29
+ sampleCount: number;
30
+ /** True if this texture is a render attachment (post-fx, MRT, depth, etc.). */
31
+ isRenderTarget: boolean;
32
+ bytes: number;
33
+ }
34
+
35
+ export interface BufferEntry {
36
+ name: string;
37
+ bytes: number;
38
+ /** Pipe-separated decoded usage flags (e.g. "STORAGE|COPY_DST"). */
39
+ usage: string;
40
+ }
41
+
42
+ export interface MemoryReport {
43
+ sampledTextures: TextureEntry[];
44
+ renderTargets: TextureEntry[];
45
+ buffers: BufferEntry[];
46
+ totals: {
47
+ sampledTextures: number;
48
+ renderTargets: number;
49
+ buffers: number;
50
+ all: number;
51
+ };
52
+ }
53
+
54
+ // ── Helpers ─────────────────────────────────────────────────────────────────
55
+
56
+ // Spec-defined WebGPU usage flag values, hardcoded so this module can be
57
+ // imported in Node / SSR environments where `GPUBufferUsage` /
58
+ // `GPUTextureUsage` global objects are not defined.
59
+ // https://www.w3.org/TR/webgpu/#typedefdef-gpubufferusageflags
60
+ const BUFFER_USAGE_NAMES: ReadonlyArray<[number, string]> = [
61
+ [0x0001, "MAP_READ"],
62
+ [0x0002, "MAP_WRITE"],
63
+ [0x0004, "COPY_SRC"],
64
+ [0x0008, "COPY_DST"],
65
+ [0x0010, "INDEX"],
66
+ [0x0020, "VERTEX"],
67
+ [0x0040, "UNIFORM"],
68
+ [0x0080, "STORAGE"],
69
+ [0x0100, "INDIRECT"],
70
+ [0x0200, "QUERY_RESOLVE"],
71
+ ];
72
+
73
+ const TEXTURE_USAGE_RENDER_ATTACHMENT = 0x10;
74
+
75
+ function decodeBufferUsage(flags: number): string {
76
+ const names: string[] = [];
77
+ for (const [flag, name] of BUFFER_USAGE_NAMES) {
78
+ if (flags & flag) names.push(name);
79
+ }
80
+ return names.join("|") || "0";
81
+ }
82
+
83
+ function isRenderAttachment(usage: number): boolean {
84
+ return Boolean(usage & TEXTURE_USAGE_RENDER_ATTACHMENT);
85
+ }
86
+
87
+
88
+ // ── Public API ──────────────────────────────────────────────────────────────
89
+
90
+ export function profileMemory(
91
+ instrumentation: DeviceInstrumentation,
92
+ ): MemoryReport {
93
+ const sampledTextures: TextureEntry[] = [];
94
+ const renderTargets: TextureEntry[] = [];
95
+
96
+ for (const { descriptor } of instrumentation.textures.values()) {
97
+ const dims = resolveExtent(descriptor.size);
98
+ const bytes = textureDescriptorBytes(descriptor);
99
+ const usage = descriptor.usage ?? 0;
100
+ const entry: TextureEntry = {
101
+ name: descriptor.label || "(unnamed)",
102
+ width: dims.width,
103
+ height: dims.height,
104
+ depth: dims.depth,
105
+ format: descriptor.format,
106
+ mipLevels: descriptor.mipLevelCount ?? 1,
107
+ sampleCount: descriptor.sampleCount ?? 1,
108
+ isRenderTarget: isRenderAttachment(usage),
109
+ bytes,
110
+ };
111
+ if (entry.isRenderTarget) renderTargets.push(entry);
112
+ else sampledTextures.push(entry);
113
+ }
114
+ sampledTextures.sort((a, b) => b.bytes - a.bytes);
115
+ renderTargets.sort((a, b) => b.bytes - a.bytes);
116
+
117
+ const buffers: BufferEntry[] = [];
118
+ for (const { descriptor } of instrumentation.buffers.values()) {
119
+ buffers.push({
120
+ name: descriptor.label || "(unnamed)",
121
+ bytes: descriptor.size,
122
+ usage: decodeBufferUsage(descriptor.usage),
123
+ });
124
+ }
125
+ buffers.sort((a, b) => b.bytes - a.bytes);
126
+
127
+ const totalSampled = sampledTextures.reduce((s, t) => s + t.bytes, 0);
128
+ const totalRT = renderTargets.reduce((s, t) => s + t.bytes, 0);
129
+ const totalBuf = buffers.reduce((s, b) => s + b.bytes, 0);
130
+
131
+ return {
132
+ sampledTextures,
133
+ renderTargets,
134
+ buffers,
135
+ totals: {
136
+ sampledTextures: totalSampled,
137
+ renderTargets: totalRT,
138
+ buffers: totalBuf,
139
+ all: totalSampled + totalRT + totalBuf,
140
+ },
141
+ };
142
+ }
143
+
144
+ /** Build a multi-line plain-text report. Feed to a copy button or bug report. */
145
+ export function reportToText(report: MemoryReport): string {
146
+ const mb = (b: number) => `${(b / 1024 / 1024).toFixed(2)} MB`;
147
+ const lines: string[] = [];
148
+ lines.push(`VRAM snapshot (live, from device.createBuffer/createTexture)`);
149
+ lines.push(``);
150
+ lines.push(`Sampled textures: ${mb(report.totals.sampledTextures)}`);
151
+ for (const t of report.sampledTextures) {
152
+ lines.push(
153
+ ` ${t.name.padEnd(32)} ${t.width}x${t.height}${t.depth > 1 ? `x${t.depth}` : ""} ${t.format} mips=${t.mipLevels} ${mb(t.bytes)}`,
154
+ );
155
+ }
156
+ lines.push(``);
157
+ lines.push(`Render targets: ${mb(report.totals.renderTargets)}`);
158
+ for (const t of report.renderTargets) {
159
+ lines.push(
160
+ ` ${t.name.padEnd(32)} ${t.width}x${t.height} ${t.format} ${mb(t.bytes)}`,
161
+ );
162
+ }
163
+ lines.push(``);
164
+ lines.push(`Buffers: ${mb(report.totals.buffers)}`);
165
+ for (const b of report.buffers) {
166
+ lines.push(` ${b.name.padEnd(32)} ${b.usage} ${mb(b.bytes)}`);
167
+ }
168
+ lines.push(``);
169
+ lines.push(`Total: ${mb(report.totals.all)}`);
170
+ return lines.join("\n");
171
+ }