diff --git a/README.md b/README.md index 50d9c2ec..9f2eb701 100644 --- a/README.md +++ b/README.md @@ -394,17 +394,17 @@ NodeJS before v10 will work by importing `@msgpack/msgpack/dist.es5/msgpack`. ## Benchmark -Benchmark on NodeJS/v12.3.1 +Benchmark on NodeJS/v12.7.0 operation | op | ms | op/s ----------------------------------------------------------------- | ------: | ----: | ------: -buf = Buffer.from(JSON.stringify(obj)); | 497600 | 5000 | 99520 -buf = JSON.stringify(obj); | 969500 | 5000 | 193900 -obj = JSON.parse(buf); | 345300 | 5000 | 69060 -buf = require("msgpack-lite").encode(obj); | 369100 | 5000 | 73820 -obj = require("msgpack-lite").decode(buf); | 278900 | 5000 | 55780 -buf = require("@msgpack/msgpack").encode(obj); | 556900 | 5000 | 111380 -obj = require("@msgpack/msgpack").decode(buf); | 502200 | 5000 | 100440 +buf = Buffer.from(JSON.stringify(obj)); | 507700 | 5000 | 101540 +buf = JSON.stringify(obj); | 958100 | 5000 | 191620 +obj = JSON.parse(buf); | 346500 | 5000 | 69300 +buf = require("msgpack-lite").encode(obj); | 361800 | 5001 | 72345 +obj = require("msgpack-lite").decode(buf); | 267400 | 5000 | 53480 +buf = require("@msgpack/msgpack").encode(obj); | 510200 | 5000 | 102040 +obj = require("@msgpack/msgpack").decode(buf); | 825500 | 5000 | 165100 Note that `Buffer.from()` for `JSON.stringify()` is added to emulate I/O where a JavaScript string must be converted into a byte array encoded in UTF-8, whereas MessagePack's `encode()` returns a byte array. diff --git a/benchmark/key-decoder.ts b/benchmark/key-decoder.ts new file mode 100644 index 00000000..a7c3a679 --- /dev/null +++ b/benchmark/key-decoder.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-console */ +import { utf8EncodeJs, utf8Count, utf8DecodeJs } from "../src/utils/utf8"; + +// @ts-ignore +import Benchmark from "benchmark"; +import { CachedKeyDecoder } from "../src/CachedKeyDecoder"; + +type InputType = { + bytes: Uint8Array; + byteLength: number; + str: string; +}; + +const keys: Array = Object.keys(require("./benchmark-from-msgpack-lite-data.json")).map((str) => { + const byteLength = utf8Count(str); + const bytes = new Uint8Array(new ArrayBuffer(byteLength)); + + utf8EncodeJs(str, bytes, 0); + + return { bytes, byteLength, str }; +}); + +for (const dataSet of [keys]) { + const keyDecoder = new CachedKeyDecoder(); + + const suite = new Benchmark.Suite(); + + suite.add("utf8DecodeJs", () => { + for (const data of dataSet) { + if (utf8DecodeJs(data.bytes, 0, data.byteLength) !== data.str) { + throw new Error("wrong result!"); + } + } + }); + + suite.add("CachedKeyDecoder", () => { + for (const data of dataSet) { + if (keyDecoder.decode(data.bytes, 0, data.byteLength) !== data.str) { + throw new Error("wrong result!"); + } + } + }); + suite.on("cycle", (event: any) => { + console.log(String(event.target)); + }); + + suite.run(); +} diff --git a/src/CachedKeyDecoder.ts b/src/CachedKeyDecoder.ts new file mode 100644 index 00000000..02c5b26a --- /dev/null +++ b/src/CachedKeyDecoder.ts @@ -0,0 +1,71 @@ +import { utf8DecodeJs } from "./utils/utf8"; + +interface KeyCacheRecord { + readonly bytes: Uint8Array; + readonly value: string; +} + +const DEFAULT_MAX_KEY_LENGTH = 16; +const DEFAULT_MAX_LENGTH_PER_KEY = 32; + +export class CachedKeyDecoder { + private readonly caches: Array>; + + constructor( + private readonly maxKeyLength = DEFAULT_MAX_KEY_LENGTH, + private readonly maxLengthPerKey = DEFAULT_MAX_LENGTH_PER_KEY, + ) { + // avoid `new Array(N)` to create a non-sparse array for performance. + this.caches = []; + for (let i = 0; i < this.maxKeyLength; i++) { + this.caches.push([]); + } + } + + public canBeCached(byteLength: number) { + return byteLength > 0 && byteLength <= this.maxKeyLength; + } + + private get(bytes: Uint8Array, inputOffset: number, byteLength: number): string | null { + const records = this.caches[byteLength - 1]; + const recordsLength = records.length; + + FIND_CHUNK: for (let i = 0; i < recordsLength; i++) { + const record = records[i]; + + for (let j = 0; j < byteLength; j++) { + if (record.bytes[j] !== bytes[inputOffset + j]) { + continue FIND_CHUNK; + } + } + return record.value; + } + return null; + } + + private store(bytes: Uint8Array, value: string) { + const records = this.caches[bytes.length - 1]; + const record: KeyCacheRecord = { bytes, value }; + + if (records.length >= this.maxLengthPerKey) { + // `records` are full! + // Set `record` to a randomized position. + records[(Math.random() * records.length) | 0] = record; + } else { + records.push(record); + } + } + + public decode(bytes: Uint8Array, inputOffset: number, byteLength: number): string { + const cachedValue = this.get(bytes, inputOffset, byteLength); + if (cachedValue) { + return cachedValue; + } + + const value = utf8DecodeJs(bytes, inputOffset, byteLength); + // Ensure to copy a slice of bytes because the byte may be NodeJS Buffer and Buffer#slice() returns a reference to its internal ArrayBuffer. + const slicedCopyOfBytes = Uint8Array.prototype.slice.call(bytes, inputOffset, inputOffset + byteLength); + this.store(slicedCopyOfBytes, value); + return value; + } +} diff --git a/src/Decoder.ts b/src/Decoder.ts index 3e3aa6c0..0e55fd28 100644 --- a/src/Decoder.ts +++ b/src/Decoder.ts @@ -4,6 +4,7 @@ import { getInt64, getUint64 } from "./utils/int"; import { utf8DecodeJs, TEXT_ENCODING_AVAILABLE, TEXT_DECODER_THRESHOLD, utf8DecodeTD } from "./utils/utf8"; import { createDataView, ensureUint8Array } from "./utils/typedArrays"; import { WASM_AVAILABLE, WASM_STR_THRESHOLD, utf8DecodeWasm } from "./wasmFunctions"; +import { CachedKeyDecoder } from "./CachedKeyDecoder"; enum State { ARRAY, @@ -50,6 +51,8 @@ const MORE_DATA = new DataViewIndexOutOfBoundsError("Insufficient data"); const DEFAULT_MAX_LENGTH = 0xffff_ffff; // uint32_max +const sharedCachedKeyDecoder = new CachedKeyDecoder(); + export class Decoder { totalPos = 0; pos = 0; @@ -59,6 +62,9 @@ export class Decoder { headByte = HEAD_BYTE_REQUIRED; readonly stack: Array = []; + // TODO: parameterize this property. + readonly cachedKeyDecoder = sharedCachedKeyDecoder; + constructor( readonly extensionCodec = ExtensionCodec.defaultCodec, readonly maxStrLength = DEFAULT_MAX_LENGTH, @@ -482,7 +488,9 @@ export class Decoder { const offset = this.pos + headerOffset; let object: string; - if (TEXT_ENCODING_AVAILABLE && byteLength > TEXT_DECODER_THRESHOLD) { + if (this.stateIsMapKey() && this.cachedKeyDecoder.canBeCached(byteLength)) { + object = this.cachedKeyDecoder.decode(this.bytes, offset, byteLength); + } else if (TEXT_ENCODING_AVAILABLE && byteLength > TEXT_DECODER_THRESHOLD) { object = utf8DecodeTD(this.bytes, offset, byteLength); } else if (WASM_AVAILABLE && byteLength > WASM_STR_THRESHOLD) { object = utf8DecodeWasm(this.bytes, offset, byteLength); @@ -493,6 +501,14 @@ export class Decoder { return object; } + stateIsMapKey(): boolean { + if (this.stack.length > 0) { + const state = this.stack[this.stack.length - 1]; + return state.type === State.MAP_KEY; + } + return false; + } + decodeBinary(byteLength: number, headOffset: number): Uint8Array { if (byteLength > this.maxBinLength) { throw new Error(`Max length exceeded: bin length (${byteLength}) > maxBinLength (${this.maxBinLength})`); diff --git a/test/CachedKeyDecoder.test.ts b/test/CachedKeyDecoder.test.ts new file mode 100644 index 00000000..63a1a558 --- /dev/null +++ b/test/CachedKeyDecoder.test.ts @@ -0,0 +1,76 @@ +import assert from "assert"; +import { CachedKeyDecoder } from "../src/CachedKeyDecoder"; +import { utf8EncodeJs, utf8Count } from "../src/utils/utf8"; + +function tryDecode(decoder: CachedKeyDecoder, str: string): string { + const byteLength = utf8Count(str); + const bytes = new Uint8Array(byteLength); + utf8EncodeJs(str, bytes, 0); + if (!decoder.canBeCached(byteLength)) { + throw new Error("Unexpected precondition"); + } + return decoder.decode(bytes, 0, byteLength); +} + +describe("CachedKeyDecoder", () => { + context("basic behavior", () => { + it("decodes a string", () => { + const decoder = new CachedKeyDecoder(); + + assert.deepStrictEqual(tryDecode(decoder, "foo"), "foo"); + assert.deepStrictEqual(tryDecode(decoder, "foo"), "foo"); + assert.deepStrictEqual(tryDecode(decoder, "foo"), "foo"); + + // console.dir(decoder, { depth: 100 }); + }); + + it("decodes strings", () => { + const decoder = new CachedKeyDecoder(); + + assert.deepStrictEqual(tryDecode(decoder, "foo"), "foo"); + assert.deepStrictEqual(tryDecode(decoder, "bar"), "bar"); + assert.deepStrictEqual(tryDecode(decoder, "foo"), "foo"); + + // console.dir(decoder, { depth: 100 }); + }); + + it("decodes strings with purging records", () => { + const decoder = new CachedKeyDecoder(16, 4); + + for (let i = 0; i < 100; i++) { + assert.deepStrictEqual(tryDecode(decoder, "foo1"), "foo1"); + assert.deepStrictEqual(tryDecode(decoder, "foo2"), "foo2"); + assert.deepStrictEqual(tryDecode(decoder, "foo3"), "foo3"); + assert.deepStrictEqual(tryDecode(decoder, "foo4"), "foo4"); + assert.deepStrictEqual(tryDecode(decoder, "foo5"), "foo5"); + } + + // console.dir(decoder, { depth: 100 }); + }); + }); + + context("edge cases", () => { + // len=0 is not supported because it is just an empty string + it("decodes str with len=1", () => { + const decoder = new CachedKeyDecoder(); + + assert.deepStrictEqual(tryDecode(decoder, "f"), "f"); + assert.deepStrictEqual(tryDecode(decoder, "a"), "a"); + assert.deepStrictEqual(tryDecode(decoder, "f"), "f"); + assert.deepStrictEqual(tryDecode(decoder, "a"), "a"); + + // console.dir(decoder, { depth: 100 }); + }); + + it("decodes str with len=maxKeyLength", () => { + const decoder = new CachedKeyDecoder(1); + + assert.deepStrictEqual(tryDecode(decoder, "f"), "f"); + assert.deepStrictEqual(tryDecode(decoder, "a"), "a"); + assert.deepStrictEqual(tryDecode(decoder, "f"), "f"); + assert.deepStrictEqual(tryDecode(decoder, "a"), "a"); + + //console.dir(decoder, { depth: 100 }); + }); + }); +}); diff --git a/test/mocha.opts b/test/mocha.opts index 0147b799..27f430fa 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,2 +1,3 @@ --require ts-node/register --require tsconfig-paths/register +--timeout 10000