diff --git a/docs/content/docs/experimental.mdx b/docs/content/docs/experimental.mdx index 4ee174fc6..64481634f 100644 --- a/docs/content/docs/experimental.mdx +++ b/docs/content/docs/experimental.mdx @@ -159,7 +159,7 @@ worker: square: 4 lines, 3 shapes
new InternTable<K, V>(name)
-
Opens the table called name. Within one thread every construction with the same name refers to the same table. In scriptc builds every thread of the process shares it; under Node.js each thread has its own. Keys are strings or numbers and compare like Map keys.
+
Opens the table called name. Within one thread every construction with the same name refers to the same table. In scriptc builds every thread of the process shares it; under Node.js each thread has its own. Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare like Map keys; arrays compare by their elements in the same way and can have at most 65536 elements.
table.get(key), table.has(key), table.size
Read the table.
table.getOrInsert(key, value)
@@ -180,7 +180,7 @@ No lock is held while a callback runs. Two threads that miss the same key at the 2. It does not compare identities or ids across threads. 3. A thread does not depend on another thread's inserts: `size` and `has` reflect every thread's inserts in scriptc and the calling thread's under Node.js. -State that a thread computes about a shared value later (lazily resolved members, caches) belongs in thread-local state, such as a module-level `Map` keyed by the value: module state is per thread in both runtimes. +State that a thread computes about a shared value later (lazily resolved members, caches) belongs in thread-local state, such as a module-level `Map` keyed by the value or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these. ### Memory diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 77db5437f..b8f25d477 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -8833,7 +8833,8 @@ export class LlEmitter { /** InternTable's runtime calls (scr_itable.c). Table and entry handles * are f64 values carrying pointer bits; entry values are 64-bit slots * (double bits, a bool, or a pointer to an immortal value) that the - * table neither retains nor releases. */ + * table neither retains nor releases. Lookups and inserts of array keys + * throw a TypeError for keys longer than a table accepts. */ private emitInternTableIntrinsic(e: ExprOf<"intrinsic">): LlValue { const B = this.B; // The runtime copies keys and stores published values without counts, @@ -8855,8 +8856,9 @@ export class LlEmitter { return { name: handle, type: e.type }; }; const keyCall = (op: "find" | "insert"): string => { - const key = e.args[1]!.type.kind === "string" ? "str" : "f64"; - const keyTy = key === "str" ? "ptr" : "double"; + const kind = e.args[1]!.type.kind; + const key = kind === "string" ? "str" : kind === "array" ? "arr" : "f64"; + const keyTy = key === "f64" ? "double" : "ptr"; const value = op === "insert" ? ", i64" : ""; this.declare(`declare ptr @scr_itable_${op}_${key}(ptr, ${keyTy}${value})`); return `@scr_itable_${op}_${key}(ptr ${toPtr(args[0]!.name)}, ${keyTy} ${args[1]!.name}`; @@ -8874,6 +8876,8 @@ export class LlEmitter { const call = keyCall("find"); const entry = B.tmp(); B.line(`${entry} = call ptr ${call})`); + // Array keys longer than a table accepts throw a TypeError. + if (e.args[1]!.type.kind === "array") this.emitPendingCheck(); return toHandle(entry); } case "itable.insert": { @@ -8893,6 +8897,7 @@ export class LlEmitter { const call = keyCall("insert"); const entry = B.tmp(); B.line(`${entry} = call ptr ${call}, i64 ${slot})`); + if (e.args[1]!.type.kind === "array") this.emitPendingCheck(); return toHandle(entry); } case "itable.value": { diff --git a/packages/compiler/src/backend/llvm/intern-tables.test.ts b/packages/compiler/src/backend/llvm/intern-tables.test.ts index d47326727..2c26c7a8f 100644 --- a/packages/compiler/src/backend/llvm/intern-tables.test.ts +++ b/packages/compiler/src/backend/llvm/intern-tables.test.ts @@ -42,6 +42,19 @@ test("InternTable's intrinsics call the runtime and read value slots directly", intrinsic("itable.value", [inserted], STRING), intrinsic("itable.size", [table], F64), intrinsic("itable.find", [intrinsic("itable.open", [str("n"), num(1)], F64), num(1)], F64), + intrinsic( + "itable.find", + [ + intrinsic("itable.open", [str("a"), num(2)], F64), + { + kind: "arrayLit", + elems: [num(1), num(2)], + type: { kind: "array", elem: F64 }, + loc, + } as IrExpr, + ], + F64, + ), ]; expect(validateModule(module(calls))).toEqual([]); const main = body(emitLlvmModule(module(calls), { pointerBits: 64 }), "sc_f_main"); @@ -50,6 +63,7 @@ test("InternTable's intrinsics call the runtime and read value slots directly", expect(main).toMatch(/call ptr @scr_itable_insert_str\(ptr %\S+, ptr @\S+, i64 %\S+\)/); expect(main).toMatch(/call ptr @scr_itable_find_f64\(ptr %\S+, double \S+\)/); expect(main).toMatch(/call double @scr_itable_size\(ptr %\S+\)/); + expect(main).toMatch(/call ptr @scr_itable_find_arr\(ptr %\S+, ptr %\S+\)/); // Entry handles reach their load through memory (x86 FastISel folds // inttoptr of a bitcast double into the address without moving it out // of its XMM register at -O0). @@ -68,7 +82,7 @@ test("the validator checks InternTable intrinsic operands", () => { ]; expect(validateModule(module(bad)).map((e) => e.message)).toEqual([ "in main: itable.open takes a string name and an f64 key kind", - "in main: itable.find key must be string or f64", + "in main: itable.find key must be string, f64, or an array of either", "in main: itable.insert takes 3 arguments", "in main: itable.size has type f64", ]); diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 9d692a15d..1c65da36f 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -196,6 +196,12 @@ export function computeMayThrow( if (rec.name === "module.tdzCheck") f.throws = true; // publish() throws on an unpublishable dyn value. if (rec.name === "threads.publish") f.throws = true; + // An InternTable array key longer than a table accepts. + if ( + (rec.name === "itable.find" || rec.name === "itable.insert") && + rec.args[1]?.type.kind === "array" + ) + f.throws = true; break; case "yieldExpr": // A consumer .throw() surfaces at the yield (and .return()'s diff --git a/packages/compiler/src/frontend/lowering/intern-tables.ts b/packages/compiler/src/frontend/lowering/intern-tables.ts index 0c94257a5..522563355 100644 --- a/packages/compiler/src/frontend/lowering/intern-tables.ts +++ b/packages/compiler/src/frontend/lowering/intern-tables.ts @@ -18,7 +18,8 @@ * publication analysis guards stores into the value's types), and reads * the entry's value (itable.value). `getOrInsertComputed` with a * function expression lowers in place instead, and creates the callback - * only when the key has no entry. + * only when the key has no entry; an arrow function with an expression + * body is not created at all: its body is lowered in place. * The entry handles that itable.find and itable.insert answer stay inside * these lowerings: f64 locals that only reach toBool and itable.value. * Any other use of the class or its members as values is refused. */ @@ -41,8 +42,15 @@ const TABLE_FIELD = "#table"; const BUILTIN_LOC: SrcLoc = { file: "", start: 0, end: 0 }; -/** Runtime key kinds (scr_itable.h SCR_ITABLE_KEY_*). */ -const KEY_KIND: Record = { string: 0, f64: 1 }; +/** The runtime key kind of a key type (scr_itable.h SCR_ITABLE_KEY_*): + * strings, numbers, and arrays of numbers or of strings. */ +function keyKind(key: IrType): number | null { + if (key.kind === "string") return 0; + if (key.kind === "f64") return 1; + if (key.kind === "array" && key.elem.kind === "f64") return 2; + if (key.kind === "array" && key.elem.kind === "string") return 3; + return null; +} /** Value types a table stores: the publishable Map value types. */ const VALUE_KINDS = new Set([ @@ -138,12 +146,12 @@ function tableTypes(lowerer: Lowerer, type: ts.Type, blame: ts.Node): TableTypes const args = table ? lowerer.checker.getTypeArguments(table as ts.TypeReference) : []; if (args.length !== 2) lowerer.noLowering("InternTable without type arguments", blame); const key = lowerer.mapTypeOf(args[0]!); - if (key === null || (key.kind !== "string" && key.kind !== "f64")) + if (key === null || keyKind(key) === null) lowerer.unsupported( "SC1090", blame, `'${lowerer.checker.typeToString(args[0]!)}' as an InternTable key type`, - "use string or number keys", + "use string or number keys, or arrays of numbers or of strings", ); const value = lowerer.mapTypeOf(args[1]!); if (value === null || !VALUE_KINDS.has(value.kind)) @@ -189,7 +197,7 @@ export function lowerInternTableNew(lowerer: Lowerer, expr: ts.NewExpression): I className: INTERN_TABLE_CLASS, args: [ { kind: "strLit", value: name, type: STRING, loc }, - { kind: "numLit", value: KEY_KIND[types.key.kind]!, type: F64, loc }, + { kind: "numLit", value: keyKind(types.key)!, type: F64, loc }, ], type: internTableType(lowerer), loc, @@ -300,7 +308,9 @@ export function lowerInternTableCall(lowerer: Lowerer, expr: ts.CallExpression): * e ? itable.value(e) : itable.value(itable.insert(t.#table, k, publish(callback(k)))) * * Creating a function has no observable effect, so the callback's closure - * is created only when the key has no entry, and a hit allocates nothing. */ + * is created only when the key has no entry, and a hit allocates nothing. + * An arrow function with an expression body is lowered in place instead + * (inlineArrowBody). */ function lowerComputedInline( lowerer: Lowerer, table: IrExpr, @@ -316,10 +326,14 @@ function lowerComputedInline( const entryLocal = lowerer.declareHiddenLocal("%internEntry", F64); const ref = (localId: string, type: IrType): IrExpr => ({ kind: "varRef", localId, type, loc }); const handle = (): IrExpr => handleOf(ref(tableLocal.id, tableT), loc); - const callback = lowerer.coerceInto(callbackNode, lowerer.lowerExpr(callbackNode), callbackType); - const computed: IrExpr = { + const computed: IrExpr = inlineArrowBody( + lowerer, + callbackNode, + ref(keyLocal.id, types.key), + types.value, + ) ?? { kind: "callValue", - callee: callback, + callee: lowerer.coerceInto(callbackNode, lowerer.lowerExpr(callbackNode), callbackType), args: [ref(keyLocal.id, types.key)], type: types.value, loc, @@ -367,6 +381,64 @@ function lowerComputedInline( }; } +/** Whether `node` contains a function or class, whose captures of the + * inlined parameter would need a closure after all. */ +function containsFunction(node: ts.Node): boolean { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return true; + return ts.forEachChild(node, (child) => (containsFunction(child) ? true : undefined)) === true; +} + +/** The body of `() => value` or `(k) => value`, lowered where the callback + * would be called, with the parameter bound to the key. Calling a fresh + * arrow function at once is evaluating its body there with its parameter + * bound, so no closure is created. Other callbacks answer null. */ +function inlineArrowBody( + lowerer: Lowerer, + node: ts.Expression, + key: IrExpr, + value: IrType, +): IrExpr | null { + if (!ts.isArrowFunction(node) || ts.isBlock(node.body)) return null; + if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return null; + const param = node.parameters[0]; + if ( + node.parameters.length > 1 || + (param !== undefined && + (!ts.isIdentifier(param.name) || + param.initializer !== undefined || + param.dotDotDotToken !== undefined)) + ) + return null; + if (containsFunction(node.body)) return null; + const body = node.body; + const loc = locOf(node); + lowerer.scopes.push(new Map()); + try { + const stmts: IrStmt[] = []; + if (param !== undefined) { + const paramTs = lowerer.typeOf(param); + const paramType = lowerer.mapTypeOf(paramTs); + if (paramType === null) lowerer.badType(param, paramTs); + const local = lowerer.declareLocal( + param.name, + (param.name as ts.Identifier).text, + paramType!, + false, + ); + stmts.push({ + kind: "varDecl", + localId: local.id, + init: lowerer.coerceToExpected(key, paramType!), + loc, + }); + } + const result = lowerer.coerceInto(body, lowerer.lowerExpr(body), value); + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: value, loc }; + } finally { + lowerer.scopes.pop(); + } +} + function helperName(lowerer: Lowerer, member: InternTableMember, signature: string): string | null { return lowerer.internTableHelpers.get(`${member}|${signature}`) ?? null; } diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index e4002b136..a4dce311e 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3481,9 +3481,12 @@ export type IrExpr = * has no entry; itable.insert (f64 table, K key, V value) → f64 the * key's entry, storing the already published value when the key has * none; itable.value (f64 entry) → V, the entry's immortal value; - * itable.size (f64 table) → f64. K is string or f64. Handles carry - * raw pointer bits and only reach toBool and these intrinsics. - * None of them throws. */ + * itable.size (f64 table) → f64. K is string, f64, or an array of + * f64 or of strings (compared by elements); find and insert throw + * a TypeError for array keys of more than 65536 elements or whose + * encoding exceeds 2^31 - 1 bytes; nothing else throws. Handles + * carry raw pointer bits and only reach toBool and these + * intrinsics. */ | "itable.open" | "itable.find" | "itable.insert" diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 5e75a2ace..3fe0f6e07 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3819,9 +3819,13 @@ function validateFunction( else if ( (e.name === "itable.find" || e.name === "itable.insert") && key!.type.kind !== "string" && - key!.type.kind !== "f64" + key!.type.kind !== "f64" && + !( + key!.type.kind === "array" && + (key!.type.elem.kind === "f64" || key!.type.elem.kind === "string") + ) ) - err(`${e.name} key must be string or f64`, e.loc); + err(`${e.name} key must be string, f64, or an array of either`, e.loc); else if (e.name === "itable.insert" && !isSupportedMapValue(value!.type)) err(`itable.insert value ${typeKey(value!.type)} is not a table value type`, e.loc); if (e.name === "itable.value") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5724-intern-table-array-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5724-intern-table-array-keys.ts.json new file mode 100644 index 000000000..624195e07 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5724-intern-table-array-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5724-intern-table-array-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5725-intern-table-callbacks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5725-intern-table-callbacks.ts.json new file mode 100644 index 000000000..87857311f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5725-intern-table-callbacks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5725-intern-table-callbacks.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5726-intern-table-key-limits.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5726-intern-table-key-limits.ts.json new file mode 100644 index 000000000..5db8a9473 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5726-intern-table-key-limits.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5726-intern-table-key-limits.ts" + ], + "diags": [] +} diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 3b22148b8..413deeff2 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -55,7 +55,7 @@ function unionOf(members: readonly Type[]): Type { ``` - `new InternTable(name)` opens the table called `name`. Within one thread, every construction with the same name refers to the same table. In scriptc builds the table is shared by every thread of the process; under Node.js each thread has its own. -- Keys are strings or numbers and compare like `Map` keys: strings by content, numbers by SameValueZero. +- Keys are strings, numbers, or arrays of numbers or of strings. Strings and numbers compare like `Map` keys: strings by content, numbers by SameValueZero. Arrays compare by their elements in the same way, so a freshly built `[a.id, b.id]` finds the value stored under an equal array; holes are equal to each other and to no number or string. An array key can have at most 65536 elements; longer ones make every method throw a `TypeError`. - `get(key)`, `has(key)` and `size` read the table. - `getOrInsert(key, value)` returns the value stored under `key`. When there is none, it publishes `value`, stores it and returns it. - `getOrInsertComputed(key, callback)` returns the value stored under `key`. When there is none, it calls `callback(key)`, publishes the result and stores it, unless a value was stored under `key` meanwhile (by the callback, or in scriptc builds by another thread), and returns the stored value. @@ -70,6 +70,6 @@ Programs that behave identically under scriptc and Node.js follow the `@scriptc/ 2. Identity and ids are not compared across threads. Ids assigned inside callbacks are unique in both runtimes but differ between them. 3. A thread does not depend on another thread's inserts. `size` and `has` reflect every thread's inserts in scriptc and the calling thread's under Node.js. -Per-thread state about a shared value (lazily computed results, caches) belongs in thread-local state, for example a module-level `Map` keyed by the value: module state is per thread in both runtimes. +Per-thread state about a shared value (lazily computed results, caches) belongs in thread-local state, for example a module-level `Map` keyed by the value, or an array indexed by an id the value carries: module state is per thread in both runtimes. In scriptc an array indexed by a dense id is the fastest of these. In scriptc builds stored values are immortal: they stay allocated until the process exits, including values a callback computed for a key that another thread stored first. diff --git a/packages/experimental/index.d.ts b/packages/experimental/index.d.ts index f072fe438..d831cbff9 100644 --- a/packages/experimental/index.d.ts +++ b/packages/experimental/index.d.ts @@ -24,13 +24,19 @@ export declare class LifetimeRegion { * publishes it, so stored values and everything they reach are immutable. * scriptc requires the name to be a string literal and every construction * of one name to use the same type arguments. */ -export declare class InternTable { +export declare class InternTable< + K extends string | number | readonly number[] | readonly string[], + V, +> { /** Opens the table called `name`. */ constructor(name: string); /** The number of entries. */ readonly size: number; - /** The value stored under `key`, or undefined. Keys compare like Map - * keys: strings by content, numbers by SameValueZero. */ + /** The value stored under `key`, or undefined. String and number keys + * compare like Map keys: strings by content, numbers by SameValueZero. + * Array keys compare by their elements in the same way; a hole and an + * undefined element are equal. Every method throws a TypeError for an + * array key of more than 65536 elements. */ get(key: K): V | undefined; /** Whether `key` has a value. */ has(key: K): boolean; diff --git a/packages/experimental/index.js b/packages/experimental/index.js index 5f1fe64fb..8a1b28c26 100644 --- a/packages/experimental/index.js +++ b/packages/experimental/index.js @@ -22,6 +22,24 @@ export class LifetimeRegion { /** This thread's tables, by name. */ const tables = new Map(); +/** The Map key of a table key: strings and numbers as they are, arrays as a + * string that encodes each element (numbers by SameValueZero, strings by + * content, holes and undefined elements alike). Arrays of more than 65536 + * elements are refused, as in scriptc builds. */ +function keyOf(key) { + if (!Array.isArray(key)) return key; + if (key.length > 65536) + throw new TypeError("InternTable array keys can have at most 65536 elements"); + let text = ""; + for (let i = 0; i < key.length; i++) { + const element = key[i]; + if (typeof element === "number") text += `n${Object.is(element, -0) ? 0 : element};`; + else if (typeof element === "string") text += `s${element.length}:${element}`; + else text += "u;"; + } + return text; +} + export class InternTable { #entries; @@ -40,21 +58,22 @@ export class InternTable { /** The value stored under `key`, or undefined. */ get(key) { - return this.#entries.get(key); + return this.#entries.get(keyOf(key)); } /** Whether `key` has a value. */ has(key) { - return this.#entries.has(key); + return this.#entries.has(keyOf(key)); } /** The value stored under `key`; when there is none, publishes `value`, * stores it and returns it. */ getOrInsert(key, value) { const entries = this.#entries; - if (entries.has(key)) return entries.get(key); + const k = keyOf(key); + if (entries.has(k)) return entries.get(k); publish(value); - entries.set(key, value); + entries.set(k, value); return value; } @@ -63,10 +82,11 @@ export class InternTable { * `key` itself. Returns the stored value. */ getOrInsertComputed(key, callback) { const entries = this.#entries; - if (entries.has(key)) return entries.get(key); + const k = keyOf(key); + if (entries.has(k)) return entries.get(k); const value = publish(callback(key)); - if (entries.has(key)) return entries.get(key); - entries.set(key, value); + if (entries.has(k)) return entries.get(k); + entries.set(k, value); return value; } } diff --git a/packages/runtime/src/scr_itable.c b/packages/runtime/src/scr_itable.c index bd67d8e9e..08db948b5 100644 --- a/packages/runtime/src/scr_itable.c +++ b/packages/runtime/src/scr_itable.c @@ -5,9 +5,13 @@ * key's 64-bit hash pick the segment and the low bits the first slot of a * linear probe in the segment's index. Number tables keep each key and its * value in the index slot itself, so a lookup touches one slot per probe. - * String tables keep entries (value, hash and key bytes) in chunks owned by - * the segment; their slots hold the entry pointer with a tag of hash bits - * 50..57 in bits 48..55, so a probe reads an entry only when the tags match. + * String and array tables keep entries (value, hash and key bytes) in + * chunks owned by the segment; their slots hold the entry pointer with a + * tag of hash bits 50..57 in bits 48..55, so a probe reads an entry only + * when the tags match. An array key is stored as one byte string that + * encodes its elements (scr_itable_encode), so array keys compare by + * their elements; keys longer or larger than a table accepts are refused + * with a TypeError before anything is encoded. * * Readers load the segment's index pointer, then slots with acquire order, * and never write. Writers hold the segment's lock: they probe again, grow @@ -15,13 +19,12 @@ * value (and the entry), and store the slot's last word with release order. * A grown index is complete before its pointer is stored with release * order, and a reader that still holds the previous one sees every key - * stored before the growth. Replaced indexes are kept on the segment's - * retired list, because readers may still be probing them (and number - * handles point into them); their total size stays below the current - * index's. + * stored before the growth. A replaced index is retired and freed once no + * thread can still be probing it (see "retired indexes" below). * - * Nothing here is freed. Every allocation is reachable from the static - * registry, so leak checkers report none of it. */ + * Tables, their entries and their current indexes are never freed. Every + * such allocation is reachable from the static registry, so leak checkers + * report none of it. */ #include "scr_runtime.h" #include "scr_itable.h" @@ -42,6 +45,12 @@ #define SCR_ITABLE_FIRST_SLOTS 8u #define SCR_ITABLE_FIRST_CHUNK ((size_t)512) #define SCR_ITABLE_MAX_CHUNK ((size_t)64 << 10) +/* Keys whose bytes (a string's, or an array key's encoding) exceed this + * are refused, which keeps every entry size representable on 32-bit + * targets. Array keys of more than SCR_ITABLE_MAX_KEY_ELEMENTS elements are + * refused too; the Node.js implementation applies the same limit. */ +#define SCR_ITABLE_MAX_KEY_BYTES ((size_t)SCR_STR_MAX_CAP) +#define SCR_ITABLE_MAX_KEY_ELEMENTS 65536u /* String slots: 0 is empty; otherwise the entry pointer with the tag in * bits 48..55, which no supported target uses for user-space addresses @@ -50,17 +59,22 @@ #define SCR_ITABLE_TAG_MASK ((uint64_t)0xff << SCR_ITABLE_TAG_SHIFT) /* Number slots are { value, key }, with the key stored as its normalized - * bits XOR this NaN pattern, which normalization never produces: a zero - * key word is an empty slot. */ + * bits XOR this NaN pattern, which normalization never produces (so no + * stored key is 0), and a tag byte of hash bits 48..55 (0 mapped to 1). */ #define SCR_ITABLE_F64_FLIP UINT64_C(0x7ff4000000000001) typedef struct ScrITableIndex { uint64_t mask; /* slots - 1 */ - struct ScrITableIndex *retired; /* the index this one replaced */ + struct ScrITableIndex *retired; /* the retired list's next index */ + uint64_t epoch; /* the epoch it was retired in */ + /* Number indexes: one tag byte per slot (0: empty), after the words. A + * probe reads the dense tags first, so a miss rarely touches a pair. */ + _Atomic uint8_t *tags; _Atomic uint64_t words[]; /* one word per string slot, two per number slot */ } ScrITableIndex; -/* A string table entry: the value slot first (scr_itable.h). */ +/* A string or array table entry: the value slot first (scr_itable.h), + * then the key's bytes. */ typedef struct { uint64_t value; uint64_t hash; @@ -125,6 +139,92 @@ static void scr_itable_unlock(atomic_uint *lock) { atomic_store_explicit(lock, 0, memory_order_release); } +/* ── retired indexes ───────────────────────────────────────────────── + * A thread that loaded a segment's index pointer before the index grew may + * still be probing the old index (or about to read a number entry handle + * that points into it). Every thread that uses a table has a reader record: + * at the start of each lookup and insert it records the global epoch it + * reads there, with release order. Growth publishes the new index, retires + * the old one under the current epoch and advances the epoch. A retired + * index is freed once every registered reader has recorded a later epoch: + * such a reader began its current operation after the growth, so it reads + * the new pointer, and the operations before, which may have read the old + * one, are complete. Entry handles are used before the thread's next lookup + * or insert, so the same rule covers them. A thread that stops using tables + * keeps its last epoch, and with it later retirements, until it uses a + * table again or exits (scr_itable_thread_exit). */ +typedef struct ScrITableReader { + _Atomic uint64_t seen; /* SCR_ITABLE_IDLE: the record is unused */ + struct ScrITableReader *next; +} ScrITableReader; + +#define SCR_ITABLE_IDLE UINT64_MAX + +static _Atomic uint64_t scr_itable_epoch = 1; +static ScrITableReader *_Atomic scr_itable_readers; +static SCR_TL ScrITableReader *scr_itable_reader; +static atomic_uint scr_itable_retired_lock; +static ScrITableIndex *scr_itable_retired; /* newest first */ + +static ScrITableReader *scr_itable_reader_new(void) { + /* Reuse the record of an exited thread, else add one. */ + for (ScrITableReader *r = atomic_load_explicit(&scr_itable_readers, memory_order_acquire); r; r = r->next) { + uint64_t idle = SCR_ITABLE_IDLE; + if (atomic_compare_exchange_strong_explicit(&r->seen, &idle, 0, memory_order_acq_rel, memory_order_relaxed)) + return r; + } + ScrITableReader *r = calloc(1, sizeof *r); + if (!r) scr_itable_oom(); + r->next = atomic_load_explicit(&scr_itable_readers, memory_order_relaxed); + while (!atomic_compare_exchange_weak_explicit(&scr_itable_readers, &r->next, r, memory_order_release, + memory_order_relaxed)) { + } + return r; +} + +/* The start of a lookup or insert: the calling thread holds nothing it + * read before. */ +static inline void scr_itable_enter(void) { + ScrITableReader *r = scr_itable_reader; + if (__builtin_expect(r == NULL, 0)) r = scr_itable_reader = scr_itable_reader_new(); + uint64_t epoch = atomic_load_explicit(&scr_itable_epoch, memory_order_acquire); + if (atomic_load_explicit(&r->seen, memory_order_relaxed) != epoch) + atomic_store_explicit(&r->seen, epoch, memory_order_release); +} + +void scr_itable_thread_exit(void) { + ScrITableReader *r = scr_itable_reader; + if (!r) return; + scr_itable_reader = NULL; + atomic_store_explicit(&r->seen, SCR_ITABLE_IDLE, memory_order_release); +} + +/* Retires `index` (the caller has published its replacement) and frees + * the retired indexes no reader can still hold. */ +static void scr_itable_retire(ScrITableIndex *index) { + scr_itable_lock(&scr_itable_retired_lock); + index->epoch = atomic_fetch_add_explicit(&scr_itable_epoch, 1, memory_order_acq_rel); + index->retired = scr_itable_retired; + scr_itable_retired = index; + uint64_t oldest = SCR_ITABLE_IDLE; + for (ScrITableReader *r = atomic_load_explicit(&scr_itable_readers, memory_order_acquire); r; r = r->next) { + uint64_t seen = atomic_load_explicit(&r->seen, memory_order_acquire); + if (seen < oldest) oldest = seen; + } + /* Free every index retired before the oldest recorded epoch. */ + ScrITableIndex **link = &scr_itable_retired; + while (*link) { + ScrITableIndex *old = *link; + if (old->epoch < oldest) { + *link = old->retired; + free(old); + } else { + link = &old->retired; + } + } + scr_itable_unlock(&scr_itable_retired_lock); +} + /* ── keys and hashes ─────────────────────────────────────────────────── */ static inline uint64_t scr_itable_f64_bits(double k) { @@ -161,6 +261,11 @@ static inline ScrITableSegment *scr_itable_segment(ScrITable *t, uint64_t hash) return &t->segments[hash >> (64 - SCR_ITABLE_SEGMENT_BITS)]; } +static inline uint8_t scr_itable_tag8(uint64_t hash) { + uint8_t tag = (uint8_t)(hash >> 48); + return tag ? tag : 1; +} + static inline uint64_t scr_itable_tag(uint64_t hash) { return ((hash >> 50) & 0xff) << SCR_ITABLE_TAG_SHIFT; } @@ -171,8 +276,8 @@ static inline const ScrITableStrEntry *scr_itable_slot_entry(uint64_t slot) { /* ── lookups ─────────────────────────────────────────────────────────── */ -static const ScrITableStrEntry *scr_itable_probe_str(const ScrITableIndex *index, uint64_t hash, - const ScrStr *key) { +static const ScrITableStrEntry *scr_itable_probe_bytes(const ScrITableIndex *index, uint64_t hash, + const char *bytes, size_t len) { if (!index) return NULL; uint64_t tag = scr_itable_tag(hash); for (uint64_t i = hash & index->mask;; i = (i + 1) & index->mask) { @@ -180,28 +285,32 @@ static const ScrITableStrEntry *scr_itable_probe_str(const ScrITableIndex *index if (slot == 0) return NULL; if ((slot & SCR_ITABLE_TAG_MASK) != tag) continue; const ScrITableStrEntry *e = scr_itable_slot_entry(slot); - if (e->hash == hash && e->len == key->len && scr_key_equal(e->data, key->data, key->len)) return e; + if (e->hash == hash && e->len == len && scr_key_equal(e->data, bytes, len)) return e; } } /* The { value, key } pair of a stored number key, or NULL. */ static _Atomic uint64_t *scr_itable_probe_f64(ScrITableIndex *index, uint64_t hash, uint64_t stored) { if (!index) return NULL; + uint8_t tag = scr_itable_tag8(hash); for (uint64_t i = hash & index->mask;; i = (i + 1) & index->mask) { - uint64_t key = atomic_load_explicit(&index->words[2 * i + 1], memory_order_acquire); - if (key == stored) return &index->words[2 * i]; - if (key == 0) return NULL; + uint8_t seen = atomic_load_explicit(&index->tags[i], memory_order_acquire); + if (seen == 0) return NULL; + if (seen == tag && atomic_load_explicit(&index->words[2 * i + 1], memory_order_relaxed) == stored) + return &index->words[2 * i]; } } const ScrITableEntry *scr_itable_find_str(ScrITable *t, const ScrStr *key) { + scr_itable_enter(); uint64_t hash = scr_itable_str_hash(key); ScrITableSegment *seg = scr_itable_segment(t, hash); - return (const ScrITableEntry *)scr_itable_probe_str( - atomic_load_explicit(&seg->index, memory_order_acquire), hash, key); + return (const ScrITableEntry *)scr_itable_probe_bytes( + atomic_load_explicit(&seg->index, memory_order_acquire), hash, key->data, key->len); } const ScrITableEntry *scr_itable_find_f64(ScrITable *t, double key) { + scr_itable_enter(); uint64_t stored = scr_itable_f64_bits(key) ^ SCR_ITABLE_F64_FLIP; uint64_t hash = scr_itable_f64_hash(stored); ScrITableSegment *seg = scr_itable_segment(t, hash); @@ -211,46 +320,58 @@ const ScrITableEntry *scr_itable_find_f64(ScrITable *t, double key) { /* ── inserts (the segment lock held) ─────────────────────────────────── */ -static ScrITableIndex *scr_itable_index_new(uint64_t slots, unsigned words) { - ScrITableIndex *index = calloc(1, sizeof(ScrITableIndex) + slots * words * sizeof(uint64_t)); +static ScrITableIndex *scr_itable_index_new(uint64_t slots, bool numbers) { + /* Bytes per slot: the words and, for numbers, the tag byte. */ + size_t per = (numbers ? 2 : 1) * sizeof(uint64_t) + (numbers ? 1 : 0); + if (slots > (SIZE_MAX - sizeof(ScrITableIndex)) / per) scr_itable_oom(); + size_t words = (size_t)slots * (numbers ? 2 : 1) * sizeof(uint64_t); + ScrITableIndex *index = calloc(1, sizeof(ScrITableIndex) + words + (numbers ? slots : 0)); if (!index) scr_itable_oom(); index->mask = slots - 1; + if (numbers) index->tags = (_Atomic uint8_t *)((char *)index->words + words); return index; } +/* Whether slot `i` holds a key (the caller holds the segment lock). */ +static inline bool scr_itable_used(const ScrITableIndex *index, uint64_t i, bool numbers) { + if (numbers) return atomic_load_explicit(&index->tags[i], memory_order_relaxed) != 0; + return atomic_load_explicit(&index->words[i], memory_order_relaxed) != 0; +} + /* Makes room for one more key: the segment's index, created or grown so * that it stays at most half full. Growth rehashes every stored key. */ static ScrITableIndex *scr_itable_reserve(ScrITableSegment *seg, bool numbers) { ScrITableIndex *index = atomic_load_explicit(&seg->index, memory_order_relaxed); uint64_t count = atomic_load_explicit(&seg->count, memory_order_relaxed); - unsigned words = numbers ? 2 : 1; if (index && (count + 1) * 2 <= index->mask + 1) return index; - ScrITableIndex *grown = scr_itable_index_new(index ? (index->mask + 1) * 2 : SCR_ITABLE_FIRST_SLOTS, words); + ScrITableIndex *grown = scr_itable_index_new(index ? (index->mask + 1) * 2 : SCR_ITABLE_FIRST_SLOTS, numbers); if (index) { for (uint64_t i = 0; i <= index->mask; i++) { - uint64_t last = atomic_load_explicit(&index->words[i * words + words - 1], memory_order_relaxed); - if (last == 0) continue; + if (!scr_itable_used(index, i, numbers)) continue; + uint64_t last = atomic_load_explicit(&index->words[numbers ? 2 * i + 1 : i], memory_order_relaxed); uint64_t hash = numbers ? scr_itable_f64_hash(last) : scr_itable_slot_entry(last)->hash; uint64_t j = hash & grown->mask; - while (atomic_load_explicit(&grown->words[j * words + words - 1], memory_order_relaxed) != 0) - j = (j + 1) & grown->mask; - if (numbers) + while (scr_itable_used(grown, j, numbers)) j = (j + 1) & grown->mask; + if (numbers) { atomic_store_explicit(&grown->words[2 * j], atomic_load_explicit(&index->words[2 * i], memory_order_relaxed), memory_order_relaxed); - atomic_store_explicit(&grown->words[j * words + words - 1], last, memory_order_relaxed); + atomic_store_explicit(&grown->words[2 * j + 1], last, memory_order_relaxed); + atomic_store_explicit(&grown->tags[j], scr_itable_tag8(hash), memory_order_relaxed); + } else { + atomic_store_explicit(&grown->words[j], last, memory_order_relaxed); + } } - grown->retired = index; } atomic_store_explicit(&seg->index, grown, memory_order_release); + if (index) scr_itable_retire(index); return grown; } /* The first empty slot of the key's probe (the key is known to be absent). */ -static uint64_t scr_itable_free_slot(const ScrITableIndex *index, uint64_t hash, unsigned words) { +static uint64_t scr_itable_free_slot(const ScrITableIndex *index, uint64_t hash, bool numbers) { uint64_t i = hash & index->mask; - while (atomic_load_explicit(&index->words[i * words + words - 1], memory_order_relaxed) != 0) - i = (i + 1) & index->mask; + while (scr_itable_used(index, i, numbers)) i = (i + 1) & index->mask; return i; } @@ -279,21 +400,23 @@ static void *scr_itable_alloc(ScrITableSegment *seg, size_t size) { return p; } -const ScrITableEntry *scr_itable_insert_str(ScrITable *t, const ScrStr *key, uint64_t value) { - if (key->len > UINT32_MAX) scr_trap("scriptc: InternTable key is too long\n"); - uint64_t hash = scr_itable_str_hash(key); +/* Stores `value` under a byte key (a string's bytes or an encoded array) + * unless the key has an entry. */ +static const ScrITableEntry *scr_itable_insert_bytes(ScrITable *t, uint64_t hash, const char *bytes, + size_t len, uint64_t value) { + if (len > SCR_ITABLE_MAX_KEY_BYTES) scr_trap("scriptc: InternTable key is too long\n"); ScrITableSegment *seg = scr_itable_segment(t, hash); scr_itable_lock(&seg->lock); const ScrITableStrEntry *found = - scr_itable_probe_str(atomic_load_explicit(&seg->index, memory_order_relaxed), hash, key); + scr_itable_probe_bytes(atomic_load_explicit(&seg->index, memory_order_relaxed), hash, bytes, len); if (!found) { ScrITableIndex *index = scr_itable_reserve(seg, false); - ScrITableStrEntry *entry = scr_itable_alloc(seg, sizeof(ScrITableStrEntry) + key->len); + ScrITableStrEntry *entry = scr_itable_alloc(seg, sizeof(ScrITableStrEntry) + len); entry->value = value; entry->hash = hash; - entry->len = (uint32_t)key->len; - memcpy(entry->data, key->data, key->len); - uint64_t i = scr_itable_free_slot(index, hash, 1); + entry->len = (uint32_t)len; + memcpy(entry->data, bytes, len); + uint64_t i = scr_itable_free_slot(index, hash, false); atomic_store_explicit(&index->words[i], (uint64_t)(uintptr_t)entry | scr_itable_tag(hash), memory_order_release); scr_itable_counted(seg); @@ -303,7 +426,13 @@ const ScrITableEntry *scr_itable_insert_str(ScrITable *t, const ScrStr *key, uin return (const ScrITableEntry *)found; } +const ScrITableEntry *scr_itable_insert_str(ScrITable *t, const ScrStr *key, uint64_t value) { + scr_itable_enter(); + return scr_itable_insert_bytes(t, scr_itable_str_hash(key), key->data, key->len, value); +} + const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t value) { + scr_itable_enter(); uint64_t stored = scr_itable_f64_bits(key) ^ SCR_ITABLE_F64_FLIP; uint64_t hash = scr_itable_f64_hash(stored); ScrITableSegment *seg = scr_itable_segment(t, hash); @@ -312,9 +441,10 @@ const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t v scr_itable_probe_f64(atomic_load_explicit(&seg->index, memory_order_relaxed), hash, stored); if (!found) { ScrITableIndex *index = scr_itable_reserve(seg, true); - uint64_t i = scr_itable_free_slot(index, hash, 2); + uint64_t i = scr_itable_free_slot(index, hash, true); atomic_store_explicit(&index->words[2 * i], value, memory_order_relaxed); - atomic_store_explicit(&index->words[2 * i + 1], stored, memory_order_release); + atomic_store_explicit(&index->words[2 * i + 1], stored, memory_order_relaxed); + atomic_store_explicit(&index->tags[i], scr_itable_tag8(hash), memory_order_release); scr_itable_counted(seg); found = &index->words[2 * i]; } @@ -322,6 +452,151 @@ const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t v return (const ScrITableEntry *)found; } +/* ── array keys ──────────────────────────────────────────────────────── */ + +/* A walk over the elements of an array in index order. Sparse entries + * are sorted by index (scr_array.h), so one pass visits each once. */ +typedef struct { + const ScrArr *a; + size_t sparse; +} ScrITableElements; + +/* The state and slot of element `i` (i < len, increasing across calls). */ +static ScrArrState scr_itable_element(ScrITableElements *w, size_t i, uint64_t *slot) { + const ScrArr *a = w->a; + if (i < a->filled) { + *slot = a->data[i]; + return SCR_ARR_VALUE; + } + if (i < a->cap) { + *slot = a->data[i]; + return (ScrArrState)a->present[i]; + } + if (a->ext) { + while (w->sparse < a->ext->sparse_len && a->ext->sparse[w->sparse].index < i) w->sparse++; + if (w->sparse < a->ext->sparse_len && a->ext->sparse[w->sparse].index == i) { + *slot = a->ext->sparse[w->sparse].slot; + return (ScrArrState)a->ext->sparse[w->sparse].state; + } + } + return SCR_ARR_HOLE; +} + +/* An array key as bytes: per number element its normalized bits, per + * string element a 32-bit length and the bytes; a hole or undefined + * element is the bits of a NaN that normalization never produces, or the + * length 0xffffffff. */ +#define SCR_ITABLE_ABSENT_F64 UINT64_C(0x7ff4000000000002) +#define SCR_ITABLE_ABSENT_STR UINT32_C(0xffffffff) + +static void scr_itable_refuse(const char *message) { + scr_throw_error_msg(SCR_ERR_TYPE, message, strlen(message)); +} + +/* The encoded size of `key`, or false (with a pending TypeError) when the + * key is longer or larger than a table accepts. Every sum stays at most + * SCR_ITABLE_MAX_KEY_BYTES, so none can wrap. */ +static bool scr_itable_encoded_size(const ScrArr *key, int kind, size_t *out) { + if (key->len > SCR_ITABLE_MAX_KEY_ELEMENTS) { + scr_itable_refuse("InternTable array keys can have at most 65536 elements"); + return false; + } + size_t n = 0; + ScrITableElements walk = {key, 0}; + for (size_t i = 0; i < key->len; i++) { + size_t add = kind == SCR_ITABLE_KEY_F64S ? 8 : 4; + if (kind != SCR_ITABLE_KEY_F64S) { + uint64_t slot = 0; + if (scr_itable_element(&walk, i, &slot) == SCR_ARR_VALUE) { + const ScrStr *s = (const ScrStr *)(uintptr_t)slot; + if (s->len > SCR_ITABLE_MAX_KEY_BYTES - add) add = SCR_ITABLE_MAX_KEY_BYTES + 1; + else add += s->len; + } + } + if (add > SCR_ITABLE_MAX_KEY_BYTES - n) { + scr_itable_refuse("InternTable array keys can encode at most 2147483647 bytes"); + return false; + } + n += add; + } + *out = n; + return true; +} + +/* Writes the encoding of `key` into `out`, which holds exactly the size + * scr_itable_encoded_size answered. */ +static void scr_itable_encode(const ScrArr *key, int kind, char *out) { + size_t n = 0; + ScrITableElements walk = {key, 0}; + for (size_t i = 0; i < key->len; i++) { + uint64_t slot = 0; + bool present = scr_itable_element(&walk, i, &slot) == SCR_ARR_VALUE; + if (kind == SCR_ITABLE_KEY_F64S) { + uint64_t bits = SCR_ITABLE_ABSENT_F64; + if (present) { + double d; + memcpy(&d, &slot, sizeof d); + bits = scr_itable_f64_bits(d); + } + memcpy(out + n, &bits, 8); + n += 8; + } else { + const ScrStr *s = present ? (const ScrStr *)(uintptr_t)slot : NULL; + uint32_t len = s ? (uint32_t)s->len : SCR_ITABLE_ABSENT_STR; + memcpy(out + n, &len, 4); + n += 4; + if (s) { + memcpy(out + n, s->data, s->len); + n += s->len; + } + } + } +} + +static inline uint64_t scr_itable_bytes_hash(const char *bytes, size_t len) { + return scr_key_mix(scr_key_hash(bytes, len) ^ ((uint64_t)len << 1)); +} + +/* Encodes `key` into `stack` when it fits, else into a heap buffer the + * caller frees; NULL (with a pending TypeError) for a refused key. */ +static char *scr_itable_encoded(const ScrITable *t, const ScrArr *key, char *stack, size_t cap, size_t *len) { + if (!scr_itable_encoded_size(key, t->key_kind, len)) return NULL; + char *bytes = stack; + if (*len > cap) { + bytes = malloc(*len); + if (!bytes) scr_itable_oom(); + } + scr_itable_encode(key, t->key_kind, bytes); + return bytes; +} + +#define SCR_ITABLE_STACK_KEY 256 + +const ScrITableEntry *scr_itable_find_arr(ScrITable *t, const ScrArr *key) { + scr_itable_enter(); + char stack[SCR_ITABLE_STACK_KEY]; + size_t len; + char *bytes = scr_itable_encoded(t, key, stack, sizeof stack, &len); + if (!bytes) return NULL; + uint64_t hash = scr_itable_bytes_hash(bytes, len); + ScrITableSegment *seg = scr_itable_segment(t, hash); + const ScrITableEntry *found = (const ScrITableEntry *)scr_itable_probe_bytes( + atomic_load_explicit(&seg->index, memory_order_acquire), hash, bytes, len); + if (bytes != stack) free(bytes); + return found; +} + +const ScrITableEntry *scr_itable_insert_arr(ScrITable *t, const ScrArr *key, uint64_t value) { + scr_itable_enter(); + char stack[SCR_ITABLE_STACK_KEY]; + size_t len; + char *bytes = scr_itable_encoded(t, key, stack, sizeof stack, &len); + if (!bytes) return NULL; + const ScrITableEntry *entry = scr_itable_insert_bytes(t, scr_itable_bytes_hash(bytes, len), bytes, len, value); + if (bytes != stack) free(bytes); + return entry; +} + double scr_itable_size(const ScrITable *t) { uint64_t total = 0; for (unsigned i = 0; i < SCR_ITABLE_SEGMENTS; i++) diff --git a/packages/runtime/src/scr_itable.h b/packages/runtime/src/scr_itable.h index f0a066124..1c198182f 100644 --- a/packages/runtime/src/scr_itable.h +++ b/packages/runtime/src/scr_itable.h @@ -3,7 +3,7 @@ #ifndef SCR_ITABLE_H #define SCR_ITABLE_H -#include "scr_string.h" +#include "scr_array.h" /* ── shared intern tables (scr_itable.c) ────────────────────────────── * InternTable (@scriptc/experimental): named, append-only tables of @@ -23,7 +23,10 @@ * which emitted code reads directly. A handle stays valid, and its slot * unchanged, for the life of the process. */ -enum { SCR_ITABLE_KEY_STR = 0, SCR_ITABLE_KEY_F64 = 1 }; +/* Key kinds: strings, numbers, and arrays of numbers or of strings, which + * compare by their elements (holes and undefined elements are equal to + * each other and to nothing else). */ +enum { SCR_ITABLE_KEY_STR = 0, SCR_ITABLE_KEY_F64 = 1, SCR_ITABLE_KEY_F64S = 2, SCR_ITABLE_KEY_STRS = 3 }; typedef struct ScrITable ScrITable; @@ -35,16 +38,22 @@ typedef struct ScrITableEntry { /* The table called `name`, created on first use. Traps when the name is * already open with another key kind. Never returns NULL. */ ScrITable *scr_itable_open(const ScrStr *name, int key_kind); -/* The entry of `key`, or NULL. */ +/* The entry of `key`, or NULL. The array variants refuse keys of more + * than 65536 elements, or whose encoding exceeds 2^31 - 1 bytes, with a + * pending TypeError (and answer NULL). */ const ScrITableEntry *scr_itable_find_str(ScrITable *t, const ScrStr *key); const ScrITableEntry *scr_itable_find_f64(ScrITable *t, double key); +const ScrITableEntry *scr_itable_find_arr(ScrITable *t, const ScrArr *key); /* Stores `value` under `key` unless the key has an entry already; answers * the key's entry either way. `value` must already be immortal (or a - * scalar): the table neither retains nor releases it. String keys are - * copied. */ + * scalar): the table neither retains nor releases it. Keys are copied. */ const ScrITableEntry *scr_itable_insert_str(ScrITable *t, const ScrStr *key, uint64_t value); const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t value); +const ScrITableEntry *scr_itable_insert_arr(ScrITable *t, const ScrArr *key, uint64_t value); /* The number of entries. */ double scr_itable_size(const ScrITable *t); +/* The calling thread uses no table again (worker exit): replaced indexes + * no longer wait for it. */ +void scr_itable_thread_exit(void); #endif /* SCR_ITABLE_H */ diff --git a/packages/runtime/src/scr_itable.test.c b/packages/runtime/src/scr_itable.test.c index 638d6c877..0f218806b 100644 --- a/packages/runtime/src/scr_itable.test.c +++ b/packages/runtime/src/scr_itable.test.c @@ -8,12 +8,20 @@ #include #include -/* The table unit needs only scr_trap from the rest of the runtime. */ +/* The table unit needs only scr_trap and scr_throw_error_msg from the + * rest of the runtime; refusals are recorded here. */ _Noreturn void scr_trap(const char *msg) { fputs(msg, stderr); abort(); } +static char refused[128]; +void scr_throw_error_msg(int kind, const char *msg, size_t len) { + assert(kind == SCR_ERR_TYPE && len < sizeof refused); + memcpy(refused, msg, len); + refused[len] = 0; +} + enum { THREADS = 8, KEYS = 20000, ROUNDS = 3 }; /* A stand-in for a published value: written by its creator before the @@ -34,6 +42,29 @@ static ScrStr *make_str(const char *text) { return s; } +static ScrArr *make_f64s(const double *values, size_t n) { + ScrArr *a = calloc(1, sizeof(ScrArr)); + assert(a); + a->rc = 1; + a->len = a->cap = n; + a->filled = (uint32_t)n; + a->elem = SCR_ELEM_F64; + a->data = calloc(n ? n : 1, sizeof(uint64_t)); + for (size_t i = 0; i < n; i++) memcpy(&a->data[i], &values[i], sizeof(double)); + return a; +} + +static ScrArr *make_strs(ScrStr *const *values, size_t n) { + ScrArr *a = make_f64s(NULL, 0); + a->len = a->cap = n; + a->filled = (uint32_t)n; + a->elem = SCR_ELEM_STR; + free(a->data); + a->data = calloc(n, sizeof(uint64_t)); + for (size_t i = 0; i < n; i++) a->data[i] = (uint64_t)(uintptr_t)values[i]; + return a; +} + static void key_text(char *out, size_t size, uint64_t k) { snprintf(out, size, "key-%llu", (unsigned long long)k); } static ScrITable *strings, *numbers; @@ -81,6 +112,7 @@ static void *run(void *arg) { assert(!scr_itable_find_f64(numbers, (double)KEYS + 0.5 + n)); } } + scr_itable_thread_exit(); return NULL; } @@ -111,6 +143,75 @@ int main(void) { free(other); free(name); + /* Array keys compare by their elements: numbers by SameValueZero, + * strings by content, holes like undefined elements. */ + ScrStr *lists_name = make_str("lists"), *paths_name = make_str("paths"); + ScrITable *lists = scr_itable_open(lists_name, SCR_ITABLE_KEY_F64S); + ScrArr *one = make_f64s((double[]){1, -0.0, 0.0 / 0.0}, 3); + ScrArr *same = make_f64s((double[]){1, 0.0, -(0.0 / 0.0)}, 3); + ScrArr *shorter = make_f64s((double[]){1, 0.0}, 2); + ScrArr *holed = make_f64s((double[]){1, 0.0, 0.0}, 3); + holed->filled = 1; + holed->present = calloc(3, 1); + holed->present[0] = SCR_ARR_VALUE; + holed->present[1] = SCR_ARR_VALUE; + holed->present[2] = SCR_ARR_HOLE; + ScrArr *empty_list = make_f64s(NULL, 0); + const ScrITableEntry *l1 = scr_itable_insert_arr(lists, one, 1); + assert(scr_itable_find_arr(lists, same) == l1 && scr_itable_insert_arr(lists, same, 2) == l1); + assert(!scr_itable_find_arr(lists, shorter) && !scr_itable_find_arr(lists, holed)); + assert(scr_itable_insert_arr(lists, holed, 3)->value == 3 && scr_itable_insert_arr(lists, shorter, 4)->value == 4); + assert(scr_itable_insert_arr(lists, empty_list, 5)->value == 5 && scr_itable_find_arr(lists, empty_list)->value == 5); + assert(scr_itable_size(lists) == 4); + ScrArr *big = make_f64s(NULL, 0); + big->len = big->cap = big->filled = 100; + free(big->data); + big->data = calloc(100, sizeof(uint64_t)); + for (unsigned i = 0; i < 100; i++) memcpy(&big->data[i], &(double){i}, sizeof(double)); + assert(scr_itable_insert_arr(lists, big, 6)->value == 6 && scr_itable_find_arr(lists, big)->value == 6); + /* Keys of more than 65536 elements are refused before anything is read, + * however long their sparse length. */ + ScrArr *longest = make_f64s(NULL, 0); + longest->len = 65536; + assert(scr_itable_insert_arr(lists, longest, 7)->value == 7 && !refused[0]); + ScrArr *huge = make_f64s(NULL, 0); + huge->len = (size_t)1 << 29; + assert(!scr_itable_find_arr(lists, huge) && !strcmp(refused, "InternTable array keys can have at most 65536 elements")); + refused[0] = 0; + longest->len = 65537; + assert(!scr_itable_insert_arr(lists, longest, 8) && refused[0]); + refused[0] = 0; + assert(scr_itable_size(lists) == 6); + ScrITable *paths = scr_itable_open(paths_name, SCR_ITABLE_KEY_STRS); + ScrStr *ab = make_str("ab"), *c = make_str("c"), *a = make_str("a"), *bc = make_str("bc"); + ScrArr *p1 = make_strs((ScrStr *[]){ab, c}, 2), *p2 = make_strs((ScrStr *[]){a, bc}, 2); + ScrArr *p3 = make_strs((ScrStr *[]){ab, c}, 2); + const ScrITableEntry *e1 = scr_itable_insert_arr(paths, p1, 7); + assert(scr_itable_insert_arr(paths, p2, 8) != e1 && scr_itable_find_arr(paths, p3) == e1); + assert(scr_itable_size(paths) == 2); + /* String keys whose encoding exceeds 2^31 - 1 bytes are refused. */ + ScrStr *wide = calloc(1, sizeof(ScrStr) + 1); + assert(wide); + wide->rc = 1; + wide->len = (size_t)0x7fffffff - 2; + ScrArr *heavy = make_strs((ScrStr *[]){wide, wide}, 2); + assert(!scr_itable_find_arr(paths, heavy) && + !strcmp(refused, "InternTable array keys can encode at most 2147483647 bytes")); + refused[0] = 0; + free(wide); + ScrArr *arrays[] = {one, same, shorter, holed, empty_list, big, longest, huge, p1, p2, p3, heavy}; + for (unsigned i = 0; i < sizeof arrays / sizeof *arrays; i++) { + free(arrays[i]->data); + free(arrays[i]->present); + free(arrays[i]); + } + free(ab); + free(c); + free(a); + free(bc); + free(lists_name); + free(paths_name); + pthread_t threads[THREADS]; for (unsigned i = 0; i < THREADS; i++) assert(pthread_create(&threads[i], NULL, run, (void *)(uintptr_t)i) == 0); diff --git a/packages/runtime/src/scr_worker.c b/packages/runtime/src/scr_worker.c index 7245c5349..4f42f9bfe 100644 --- a/packages/runtime/src/scr_worker.c +++ b/packages/runtime/src/scr_worker.c @@ -228,6 +228,8 @@ static void scr_worker_thread_run(ScrWorkerThread *worker) { * only once none of those operations can touch the worker's script heap. */ scr_mailbox_post(worker->parent, scr_worker_event(SCR_WORKER_EXIT, worker->id, code, NULL)); scr_worker_thread_release(worker); + /* Replaced InternTable indexes no longer wait for this thread. */ + scr_itable_thread_exit(); /* The next script thread continues this thread's allocator arena. */ scr_sa_thread_park(); } diff --git a/tests/corpus/5724-intern-table-array-keys.ts b/tests/corpus/5724-intern-table-array-keys.ts new file mode 100644 index 000000000..f36cc860a --- /dev/null +++ b/tests/corpus/5724-intern-table-array-keys.ts @@ -0,0 +1,79 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; +import { InternTable } from "@scriptc/experimental"; + +// InternTable (@scriptc/experimental) with array keys: arrays of numbers or +// of strings compare by their elements (numbers by SameValueZero, strings +// by content), so a freshly built key finds the value stored under an equal +// one. Workers intern list types keyed by their member ids. Node.js prints +// the same. + +class List { + readonly members: readonly number[]; + readonly label: string; + constructor(members: readonly number[], label: string) { + this.members = members; + this.label = label; + } +} + +const lists = new InternTable("arrays.lists"); +const paths = new InternTable("arrays.paths"); + +function listOf(members: readonly number[]): List { + return lists.getOrInsertComputed(members, (key) => new List([...key], key.join("|"))); +} + +if (isMainThread) { + const a = listOf([1, 2, 3]); + const b = listOf([1, 2, 3]); + const c = listOf([1, 2]); + const d = listOf([3, 2, 1]); + console.log(a === b, a === c, a === d, a.label, c.label, d.label, lists.size); + console.log(lists.get([1, 2, 3]) === a, lists.get([1, 2, 3, 4]), lists.has([1, 2]), lists.has([2, 1])); + // SameValueZero elements. + const z = listOf([-0, NaN]); + console.log(listOf([0, 0 / 0]) === z, lists.has([0, NaN]), lists.has([NaN, 0]), lists.size); + // Empty and long keys. + const empty = listOf([]); + const long: number[] = []; + for (let i = 0; i < 500; i++) long.push(i * 1.5); + const big = listOf(long); + console.log(listOf([]) === empty, empty.label === "", listOf([...long]) === big, big.members.length); + long.push(1); + console.log(lists.has(long), lists.size); + // Holes differ from numbers. + const holed = new Array(3); + holed[0] = 1; + const again = new Array(3); + again[0] = 1; + console.log(listOf(holed).members.length, listOf(again) === listOf(holed), lists.has([1, 0, 0]), lists.has([1])); + + // String arrays compare by content, element by element. + console.log(paths.getOrInsert(["ab", "c"], 1), paths.getOrInsert(["a", "bc"], 2), paths.getOrInsert(["ab", "c"], 3)); + console.log(paths.get(["a" + "b", "c"]), paths.get(["abc"]), paths.get([]), paths.getOrInsert([], 4), paths.get([""])); + console.log(paths.getOrInsert([""], 5), paths.getOrInsert(["", ""], 6), paths.get([""]), paths.size); + + const results: string[] = []; + let pending = 3; + for (let w = 0; w < 3; w++) { + const worker = new Worker(new URL(import.meta.url), { workerData: { seed: w } }); + worker.on("message", (message: unknown) => { + results[w] = message as string; + }); + worker.on("exit", () => { + if (--pending === 0) for (const line of results) console.log(line); + }); + } +} else { + const { seed } = workerData as { seed: number }; + let total = 0; + let bad = 0; + for (let n = 0; n < 5000; n++) { + const k = (n * 13 + seed * 1667) % 5000; + const members = [k % 7, k % 11, k]; + const list = listOf(members); + if (list.label !== members.join("|")) bad++; + total += list.members[2]!; + } + parentPort!.postMessage(`worker ${seed}: total=${total} bad=${bad}`); +} diff --git a/tests/corpus/5725-intern-table-callbacks.ts b/tests/corpus/5725-intern-table-callbacks.ts new file mode 100644 index 000000000..ec3fb57f0 --- /dev/null +++ b/tests/corpus/5725-intern-table-callbacks.ts @@ -0,0 +1,73 @@ +import { InternTable } from "@scriptc/experimental"; + +// InternTable.getOrInsertComputed callbacks (@scriptc/experimental): the +// callback runs only when the key has no value, sees the key and the +// enclosing scope, and its side effects happen in the order Node.js runs +// them. Node.js prints the same. + +class Item { + readonly label: string; + readonly n: number; + constructor(label: string, n: number) { + this.label = label; + this.n = n; + } +} + +const items = new InternTable("callbacks.items"); +const log: string[] = []; +let made = 0; +function make(label: string): Item { + log.push(`make ${label}`); + return new Item(label, ++made); +} + +// Expression arrows, with and without the key parameter. +console.log(items.getOrInsertComputed("a", (k) => make(k)).n, items.getOrInsertComputed("a", (k) => make(k)).n); +console.log(items.getOrInsertComputed("b", () => make("bee")).label, made); +// A parameter that shadows an outer binding, and a wider parameter type. +const k = "outer"; +console.log(items.getOrInsertComputed("c", (k) => new Item(k + "!", 0)).label, k); +console.log(items.getOrInsertComputed("d", (key: string | number) => new Item(`${typeof key}:${key}`, 1)).label); +// The enclosing scope: reads and writes of outer variables. +let counter = 10; +const e = items.getOrInsertComputed("e", () => new Item("e", counter++)); +console.log(e.n, counter, items.getOrInsertComputed("e", () => new Item("e", counter++)).n, counter); +// Nested interning inside a callback. +function chain(depth: number): Item { + return items.getOrInsertComputed(`chain${depth}`, (key) => + depth === 0 ? new Item(key, 0) : new Item(`${key}>${chain(depth - 1).label}`, depth), + ); +} +console.log(chain(3).label, chain(3) === chain(3), items.has("chain0")); +// A callback that stores its own key, and one that throws. +const nested = items.getOrInsertComputed("self", (key) => (items.getOrInsert(key, new Item("inner", 1)), new Item("outer", 2))); +console.log(nested.label, items.get("self")?.label); +try { + items.getOrInsertComputed("boom", (key) => (key.length > 0 ? (() => { throw new Error(`no ${key}`); })() : new Item(key, 0))); +} catch (error) { + console.log((error as Error).message, items.has("boom")); +} +// Block bodies and function expressions. +console.log( + items.getOrInsertComputed("f", (key) => { + log.push(`block ${key}`); + return new Item(key, 3); + }).n, + items.getOrInsertComputed("g", function (key: string) { + return new Item(key.toUpperCase(), 4); + }).label, +); +// Methods that intern with `this` in the callback. +class Factory { + readonly prefix: string; + constructor(prefix: string) { + this.prefix = prefix; + } + item(name: string): Item { + return items.getOrInsertComputed(`${this.prefix}${name}`, (key) => new Item(`${key}/${this.prefix}`, 5)); + } +} +const factory = new Factory("p-"); +console.log(factory.item("x").label, factory.item("x") === factory.item("x")); +console.log(log.join("; "), items.size); diff --git a/tests/corpus/5726-intern-table-key-limits.ts b/tests/corpus/5726-intern-table-key-limits.ts new file mode 100644 index 000000000..cd87f1b3a --- /dev/null +++ b/tests/corpus/5726-intern-table-key-limits.ts @@ -0,0 +1,39 @@ +import { InternTable } from "@scriptc/experimental"; + +// InternTable (@scriptc/experimental) array keys can have at most 65536 +// elements: longer keys, including sparse arrays with a large length, make +// every method throw a TypeError and store nothing. Node.js prints the same. + +const lists = new InternTable("limits.lists"); +const names = new InternTable("limits.names"); + +function attempt(what: string, run: () => unknown): void { + try { + console.log(what, run()); + } catch (error) { + console.log(what, (error as Error).name, (error as Error).message); + } +} + +const longest = new Array(65536); +longest[0] = 1; +attempt("longest", () => lists.getOrInsert(longest, 1)); +attempt("longest again", () => lists.get(longest)); + +const longer = new Array(65537); +attempt("longer get", () => lists.get(longer)); +attempt("longer has", () => lists.has(longer)); +attempt("longer insert", () => lists.getOrInsert(longer, 2)); +attempt("longer computed", () => lists.getOrInsertComputed(longer, () => 3)); + +const sparse: number[] = [1, 2]; +sparse.length = 2 ** 29; +attempt("sparse get", () => lists.get(sparse)); +attempt("sparse insert", () => lists.getOrInsert(sparse, 4)); +attempt("sparse computed", () => lists.getOrInsertComputed(sparse, () => 5)); + +const words: string[] = ["a"]; +words.length = 2 ** 30; +attempt("words get", () => names.get(words)); +attempt("words insert", () => names.getOrInsert(words, 6)); +console.log(lists.size, names.size, lists.get([1]), names.get(["a"])); diff --git a/tests/harness/__snapshots__/intern-table-fences.ts.txt b/tests/harness/__snapshots__/intern-table-fences.ts.txt index 51186d9d2..e06c04c9b 100644 --- a/tests/harness/__snapshots__/intern-table-fences.ts.txt +++ b/tests/harness/__snapshots__/intern-table-fences.ts.txt @@ -23,7 +23,7 @@ intern-table-fences.ts:11:15 - error SC1090: 'string | number' as an InternTable | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 12 | const callbacks = new InternTable number>("fences.callbacks"); - hint: use string or number keys + hint: use string or number keys, or arrays of numbers or of strings intern-table-fences.ts:12:19 - error SC1090: '() => number' as an InternTable value type is not supported yet diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts index 0df85a68d..178ed2191 100644 --- a/tests/harness/wasi-differential.test.ts +++ b/tests/harness/wasi-differential.test.ts @@ -305,6 +305,8 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => { // InternTable handles carry 32-bit pointers in f64 slots. "5720-intern-table-basics.ts", "5721-intern-table-values.ts", + // Array key sizes are checked in 32-bit size_t arithmetic. + "5726-intern-table-key-limits.ts", ])("the LLVM target runs %s byte-identically to Node", async (fixture) => { const entry = join(repoRoot, "tests/corpus", fixture); const outDir = await mkdtemp("/tmp/scriptc-wasi-");