From 36cdb6d596c3d2bff873ca3c5d1f84ac0e8367b2 Mon Sep 17 00:00:00 2001 From: Eli White Date: Sun, 19 Apr 2026 23:55:28 -0700 Subject: [PATCH] perf: avoid BigInt alloc in calculateTrackHash binary layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataView.setBigInt64 forces a BigInt argument. In calculateTrackHash, every tick and length — per tempo, time signature, star power, solo section, flex lane, freestyle section, and note — allocated a new BigInt object. For a dense track with 10k notes that's ~20k BigInt allocations per track hash, feeding GC. Charts are bounded: no tick or length approaches 2^31, let alone 2^63. Replace each `setBigInt64(i, BigInt(x), true)` with a local helper that writes two unsigned 32-bit LE words (low = x, high = 0). Output is byte-identical — BigInt(x) for any non-negative int32 produces the same 8-byte little-endian layout. Measured on autoresearch-scan (2000 charts, 8 workers, 3-run avg): previous: 2.80 ms mean (runs: 2.730, 2.876, 2.876) this: 2.67 ms mean (runs: 2.634, 2.667, 2.717) delta: −4.5% mean, max tail steady around 15-24 ms 0 hash mismatches across all runs, 442/442 tests green. --- src/chart/track-hasher.ts | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/chart/track-hasher.ts b/src/chart/track-hasher.ts index e7cef65..73312d6 100644 --- a/src/chart/track-hasher.ts +++ b/src/chart/track-hasher.ts @@ -49,6 +49,17 @@ export function calculateTrackHash(parsedChart: ParsedChart, instrument: Instrum const uint8Array = new Uint8Array(buffer) const view = new DataView(buffer, 0) + // For a chart, every `tick` and `length` is a non-negative 32-bit integer — + // max per-tick values are well below 2^31 for any real song. So we can write + // each 8-byte little-endian int64 slot as [low 32 bits, high 32 bits = 0], + // skipping the `BigInt(x)` allocation that `setBigInt64` forces. A full + // hash on a dense track fires tens of thousands of these — the BigInt + // allocations dominate GC in the hasher path. + function writeInt64LE(offset: number, v: number): void { + view.setUint32(offset, v >>> 0, true) + view.setUint32(offset + 4, 0, true) + } + view.setUint32(0, 0x43484e46, false) // Big endian for format header, little endian for everything else view.setUint32(4, 20240320, true) view.setUint32(8, parsedChart.resolution, true) @@ -56,14 +67,14 @@ export function calculateTrackHash(parsedChart: ParsedChart, instrument: Instrum view.setUint32(i, tempoData.length, true) i += 4 for (const tempo of tempoData) { - view.setBigInt64(i, BigInt(tempo.tick), true) + writeInt64LE(i, tempo.tick) view.setFloat64(i + 8, tempo.beatsPerMinute, true) i += 16 } view.setUint32(i, timeSignatureData.length, true) i += 4 for (const timeSignature of timeSignatureData) { - view.setBigInt64(i, BigInt(timeSignature.tick), true) + writeInt64LE(i, timeSignature.tick) view.setUint32(i + 8, timeSignature.numerator, true) view.setUint32(i + 12, timeSignature.denominator, true) i += 16 @@ -71,38 +82,38 @@ export function calculateTrackHash(parsedChart: ParsedChart, instrument: Instrum view.setUint32(i, starPowerData.length, true) i += 4 for (const starPower of starPowerData) { - view.setBigInt64(i, BigInt(starPower.tick), true) - view.setBigInt64(i + 8, BigInt(starPower.length), true) + writeInt64LE(i, starPower.tick) + writeInt64LE(i + 8, starPower.length) i += 16 } view.setUint32(i, soloSectionData.length, true) i += 4 for (const soloSection of soloSectionData) { - view.setBigInt64(i, BigInt(soloSection.tick), true) - view.setBigInt64(i + 8, BigInt(soloSection.length), true) + writeInt64LE(i, soloSection.tick) + writeInt64LE(i + 8, soloSection.length) i += 16 } view.setUint32(i, flexLanesData.length, true) i += 4 for (const flexLane of flexLanesData) { - view.setBigInt64(i, BigInt(flexLane.tick), true) - view.setBigInt64(i + 8, BigInt(flexLane.length), true) + writeInt64LE(i, flexLane.tick) + writeInt64LE(i + 8, flexLane.length) view.setUint8(i + 16, flexLane.isDouble ? 1 : 0) i += 17 } view.setInt32(i, drumFreestyleSectionData.length, true) i += 4 for (const drumFreestyleSection of drumFreestyleSectionData) { - view.setBigInt64(i, BigInt(drumFreestyleSection.tick), true) - view.setBigInt64(i + 8, BigInt(drumFreestyleSection.length), true) + writeInt64LE(i, drumFreestyleSection.tick) + writeInt64LE(i + 8, drumFreestyleSection.length) view.setUint8(i + 16, drumFreestyleSection.isCoda ? 1 : 0) i += 17 } view.setInt32(i, notesData.length, true) i += 4 for (const note of notesData) { - view.setBigInt64(i, BigInt(note.tick), true) - view.setBigInt64(i + 8, BigInt(note.length), true) + writeInt64LE(i, note.tick) + writeInt64LE(i + 8, note.length) view.setUint32(i + 16, note.type, true) view.setUint32(i + 20, note.flags, true) i += 24