From 042bb4bed922d71a037ed0cb15f0f56a067d67af Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:42:23 -0800 Subject: [PATCH] Optimize Emitter.fire for 0 and 1 listeners Before: Context "Emitter.fire()" Context "0 listeners" Case "#1" : 1 runs - average throughput: 97.77 MB/s Context "1 listener" Case "#1" : 1 runs - average throughput: 49.30 MB/s Context "2 listeners" Case "#1" : 1 runs - average throughput: 35.37 MB/s Context "5 listeners" Case "#1" : 1 runs - average throughput: 15.32 MB/s After: Context "Emitter.fire()" Context "0 listeners" Case "#1" : 1 runs - average throughput: 376.94 MB/s Context "1 listener" Case "#1" : 1 runs - average throughput: 84.29 MB/s Context "2 listeners" Case "#1" : 1 runs - average throughput: 32.36 MB/s Context "5 listeners" Case "#1" : 1 runs - average throughput: 15.93 MB/s --- src/common/Event.test.ts | 72 +++++++++++++++++++++++++++++++ src/common/Event.ts | 18 ++++++-- test/benchmark/Event.benchmark.ts | 72 +++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/common/Event.test.ts create mode 100644 test/benchmark/Event.benchmark.ts diff --git a/src/common/Event.test.ts b/src/common/Event.test.ts new file mode 100644 index 00000000..8d1cfadd --- /dev/null +++ b/src/common/Event.test.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2024-2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { Emitter } from 'common/Event'; + +describe('Emitter', () => { + it('should fire with 0 listeners without error', () => { + const emitter = new Emitter(); + emitter.fire(42); + }); + + it('should fire with 1 listener', () => { + const emitter = new Emitter(); + let received: number | undefined; + emitter.event(e => { received = e; }); + emitter.fire(42); + assert.strictEqual(received, 42); + }); + + it('should fire with 1 listener using thisArgs', () => { + const emitter = new Emitter(); + const obj = { value: 0, handler(e: number) { this.value = e; } }; + emitter.event(obj.handler, obj); + emitter.fire(42); + assert.strictEqual(obj.value, 42); + }); + + it('should fire with multiple listeners', () => { + const emitter = new Emitter(); + const results: number[] = []; + emitter.event(e => results.push(e * 1)); + emitter.event(e => results.push(e * 2)); + emitter.event(e => results.push(e * 3)); + emitter.fire(10); + assert.deepEqual(results, [10, 20, 30]); + }); + + it('should handle listener removal during fire', () => { + const emitter = new Emitter(); + const results: string[] = []; + emitter.event(() => results.push('first')); + const disposable = emitter.event(() => { + results.push('second'); + disposable.dispose(); + }); + emitter.event(() => results.push('third')); + emitter.fire(1); + assert.deepEqual(results, ['first', 'second', 'third']); + }); + + it('should not fire after dispose', () => { + const emitter = new Emitter(); + let called = false; + emitter.event(() => { called = true; }); + emitter.dispose(); + emitter.fire(42); + assert.strictEqual(called, false); + }); + + it('should allow disposing a listener', () => { + const emitter = new Emitter(); + let count = 0; + const disposable = emitter.event(() => { count++; }); + emitter.fire(1); + disposable.dispose(); + emitter.fire(2); + assert.strictEqual(count, 1); + }); +}); diff --git a/src/common/Event.ts b/src/common/Event.ts index f9fcb4e7..7173a955 100644 --- a/src/common/Event.ts +++ b/src/common/Event.ts @@ -53,10 +53,20 @@ export class Emitter { if (this._disposed) { return; } - // Snapshot listeners to allow modifications during iteration - const listeners = this._listeners.slice(); - for (const { fn, thisArgs } of listeners) { - fn.call(thisArgs, event); + switch (this._listeners.length) { + case 0: return; + case 1: { + const { fn, thisArgs } = this._listeners[0]; + fn.call(thisArgs, event); + return; + } + default: { + // Snapshot listeners to allow modifications during iteration (2+ listeners) + const listeners = this._listeners.slice(); + for (const { fn, thisArgs } of listeners) { + fn.call(thisArgs, event); + } + } } } diff --git a/test/benchmark/Event.benchmark.ts b/test/benchmark/Event.benchmark.ts new file mode 100644 index 00000000..76f23f91 --- /dev/null +++ b/test/benchmark/Event.benchmark.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, ThroughputRuntimeCase } from 'xterm-benchmark'; +import { Emitter } from 'common/Event'; + +const ITERATIONS = 1_000_000; + +perfContext('Emitter.fire()', () => { + perfContext('0 listeners', () => { + let emitter: Emitter; + before(() => { + emitter = new Emitter(); + }); + new ThroughputRuntimeCase('', () => { + for (let i = 0; i < ITERATIONS; i++) { + emitter.fire(i); + } + return { payloadSize: ITERATIONS }; + }, { fork: false }).showAverageThroughput(); + }); + + perfContext('1 listener', () => { + let emitter: Emitter; + let sum = 0; + before(() => { + emitter = new Emitter(); + emitter.event(e => { sum += e; }); + }); + new ThroughputRuntimeCase('', () => { + for (let i = 0; i < ITERATIONS; i++) { + emitter.fire(i); + } + return { payloadSize: ITERATIONS }; + }, { fork: false }).showAverageThroughput(); + }); + + perfContext('2 listeners', () => { + let emitter: Emitter; + let sum = 0; + before(() => { + emitter = new Emitter(); + emitter.event(e => { sum += e; }); + emitter.event(e => { sum += e * 2; }); + }); + new ThroughputRuntimeCase('', () => { + for (let i = 0; i < ITERATIONS; i++) { + emitter.fire(i); + } + return { payloadSize: ITERATIONS }; + }, { fork: false }).showAverageThroughput(); + }); + + perfContext('5 listeners', () => { + let emitter: Emitter; + let sum = 0; + before(() => { + emitter = new Emitter(); + for (let j = 0; j < 5; j++) { + emitter.event(e => { sum += e; }); + } + }); + new ThroughputRuntimeCase('', () => { + for (let i = 0; i < ITERATIONS; i++) { + emitter.fire(i); + } + return { payloadSize: ITERATIONS }; + }, { fork: false }).showAverageThroughput(); + }); +});