From 49458a1ce4dd05c01a23240395396960ad64f0ba Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 14 Nov 2026 13:42:41 +0000 Subject: [PATCH] JsonValue: an object of 16 members or more finds a member through a table Every get, has and set looked through all the names, so reading an object of n members cost n * n: JSON.parse of one object of 32,000 keys took 1072 ms. It now takes 17 ms (8,000 keys: 62 ms to 5 ms). The table is an open-addressed int[] of name positions inside JsonValue, hashed with HashHelpers.hashCodeString. It is made when an object reaches JSON_INDEX_FROM (16) members and remade, twice the size, when half full, in set and in clone, so a lookup only reads it. Smaller objects are looked through as before. Not a Map: one built into the library would be built again into a program that uses the same Map. lib.d.ts declares the new private field, as it does the others. Cost: every JsonValue carries one more field, and parsing many small objects is about 10% slower (80,000 four-key objects: 184 ms to 202 ms). Test: json_many_keys.ts. Tested in release on Windows x64 for gc, rc, none and own, compiled and under the JIT; debug, Linux, x86 and wasm were not built locally. Co-Authored-By: Claude Fable 5.2 --- src/lib.d.ts | 1 + src/lib.ts | 81 +++++++++++++++++++++++++++++++++++++++-- tests/json_many_keys.ts | 67 ++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 tests/json_many_keys.ts diff --git a/src/lib.d.ts b/src/lib.d.ts index 55a401d..721330b 100644 --- a/src/lib.d.ts +++ b/src/lib.d.ts @@ -705,6 +705,7 @@ declare class JsonValue { private items: JsonValue[]; private names: string[]; private absent: JsonValue[]; + private slots: int[]; constructor(); diff --git a/src/lib.ts b/src/lib.ts index 2adabfe..1fc348a 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -2253,6 +2253,8 @@ const JSON_ARRAY = 4; const JSON_OBJECT = 5; // A parser and a writer that recurse: past this a deep input would run off the native stack. const JSON_MAX_DEPTH = 512; +// An object with this many members or more finds a member by its name through a table. +const JSON_INDEX_FROM = 16; function jsonAppend(output: char[], text: string): void { // the length once: it is a walk to the text's end each time it is asked for @@ -2327,6 +2329,10 @@ export class JsonValue { // holds the JSON null `at` and `get` give for what is not there: under -mm=own what they return // is lent by this value, so a new one could not go back the same way private absent: JsonValue[]; + // For an object of JSON_INDEX_FROM members or more: an open-addressed table of the names, each + // slot a name's position plus one, 0 an empty one. Empty for anything smaller. Not a Map: one + // built into the library would be built again into a program that uses the same Map. + private slots: int[]; // JSON's null public constructor() { @@ -2337,6 +2343,7 @@ export class JsonValue { this.items = []; this.names = []; this.absent = []; + this.slots = []; } public static ofBoolean(value: boolean): JsonValue { @@ -2494,6 +2501,7 @@ export class JsonValue { position = this.items.length; // a copy: only this path takes the key, and under -mm=own a parameter is taken on every path or none this.names.push(key + ""); + this.indexAdded(position); this.items.length = position + 1; } } @@ -2519,6 +2527,8 @@ export class JsonValue { result.names.push(name + ""); } + // the copy finds its names as this one does + result.indexAdded(result.names.length - 1); return result; } @@ -2562,12 +2572,77 @@ export class JsonValue { return this.absent[0]; } + // Where the key is among the names, or -1. A few names are looked through; from JSON_INDEX_FROM + // on they are found through `slots`, as looking through them for every member made reading an + // object of n members cost n * n. private indexOf(key: string): int { - for (let i = 0; i < this.names.length; i++) { - if (this.names[i] == key) return i; + const size: int = this.slots.length; + if (size == 0) { + const count: int = this.names.length; + for (let i = 0; i < count; i++) { + if (this.names[i] == key) return i; + } + + return -1; } - return -1; + let slot: int = (HashHelpers.hashCodeString(key) % size); + while (true) { + const at = this.slots[slot]; + if (at == 0) { + return -1; + } + + if (this.names[at - 1] == key) { + return at - 1; + } + + slot++; + if (slot == size) { + slot = 0; + } + } + } + + // puts the name at `position` into the table, which has a free slot for it + private addSlot(position: int): void { + const size: int = this.slots.length; + let slot: int = (HashHelpers.hashCodeString(this.names[position]) % size); + while (this.slots[slot] != 0) { + slot++; + if (slot == size) { + slot = 0; + } + } + + this.slots[slot] = position + 1; + } + + // Called once a name has been added at `position`. The table is made when there are + // JSON_INDEX_FROM names, and again, twice the size, whenever it is half full: it then holds + // every name, so indexOf only reads it. + private indexAdded(position: int): void { + const count: int = this.names.length; + if (count < JSON_INDEX_FROM) { + return; + } + + if (this.slots.length >= count * 2) { + this.addSlot(position); + return; + } + + const size: int = count * 4; + let slots: int[] = []; + slots.length = size; + for (let i = 0; i < size; i++) { + slots[i] = 0; + } + + this.slots = slots; + for (let i = 0; i < count; i++) { + this.addSlot(i); + } } private expect(tag: int, name: string): void { diff --git a/tests/json_many_keys.ts b/tests/json_many_keys.ts new file mode 100644 index 0000000..f65ef74 --- /dev/null +++ b/tests/json_many_keys.ts @@ -0,0 +1,67 @@ +// An object with many members finds each by its name through a table (16 members or more), where +// every name was looked through: reading an object of n members cost n * n. +function build(count: int): string { + let text = "{"; + for (let i = 0; i < count; i++) { + if (i > 0) text += ","; + text += `"key${i}":${i}`; + } + + return text + "}"; +} + +function check(count: int) { + const value = JSON.parse(build(count)); + assert(value.length == count, "every member is there"); + for (let i = 0; i < count; i++) { + assert(value.has(`key${i}`), "has each key"); + assert(value.get(`key${i}`).asNumber() == i, "each key has its own value"); + } + + assert(!value.has("key"), "a name that is the start of others"); + assert(!value.has(`key${count}`), "one past the last"); + assert(value.get("missing").isNull(), "null for what is not there"); +} + +function main() { + // around the size the table starts at, and well past it, where it has grown several times + check(0); + check(1); + check(15); + check(16); + check(17); + check(33); + check(5000); + + // a name given twice keeps its place and takes the last value, small and large + const twice = JSON.parse(`{"a":1,"b":2,"a":3}`); + assert(twice.length == 2 && twice.get("a").asNumber() == 3, "the last value, a few members"); + + const large = JSON.parse(build(100)); + large.set("key7", JsonValue.ofNumber(700)); + assert(large.length == 100, "a member set again is not added"); + assert(large.get("key7").asNumber() == 700, "and has the new value"); + large.set("extra", JsonValue.ofString("x")); + assert(large.length == 101 && large.get("extra").asString() == "x", "a member added"); + assert(large.get("key99").asNumber() == 99, "the others are as they were"); + + // built by hand, past the size the table starts at + const made = JsonValue.newObject(); + for (let i = 0; i < 40; i++) { + made.set(`n${i}`, JsonValue.ofNumber(i * 2)); + } + + assert(made.get("n39").asNumber() == 78 && made.get("n0").asNumber() == 0, "built by hand"); + const names = made.keys(); + assert(names.length == 40 && names[0] == "n0" && names[39] == "n39", "the keys keep their order"); + + // a copy finds its members too + const copy = made.clone(); + assert(copy.length == 40 && copy.get("n20").asNumber() == 40 && !copy.has("n40"), "a copy"); + + // and the text is the same after a round trip + const text = made.stringify(); + assert(JSON.parse(text).stringify() == text, "a round trip"); + + console.log("ALL DONE"); +}