From 2c44d6fec3f835cf694ea0982b62267c761296c0 Mon Sep 17 00:00:00 2001 From: Jairus Tanaka Date: Thu, 23 Jul 2026 19:24:16 -0700 Subject: [PATCH 1/4] perf: optimize JSON struct deserialization --- CONTEXT.md | 29 +++++++++++++++++++ assembly/__benches__/classic/canada.bench.ts | 15 +++++++++- .../classic/github_events.bench.ts | 15 +++++++++- assembly/__benches__/classic/twitter.bench.ts | 15 +++++++++- assembly/__benches__/large.bench.ts | 22 ++++++++++++-- assembly/__benches__/medium.bench.ts | 22 ++++++++++++-- assembly/__benches__/small.bench.ts | 22 ++++++++++++-- assembly/deserialize/parseMode.ts | 22 +++++++++++++- 8 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..65ba8bae --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# JSON-AS + +JSON-AS maps JSON documents to strongly typed AssemblyScript values, with schema-derived behavior optimized for predictable execution. + +## Language + +**JSON Struct**: +An AssemblyScript class whose JSON shape is declared with `@json` and can be serialized or deserialized according to that shape. +_Avoid_: JSON model, decorated object + +**Generated Codec**: +The schema-derived serialization and deserialization behavior associated with a JSON Struct. +_Avoid_: generated serde, generated parser + +**Canonical Input**: +A compact JSON object whose keys appear in the JSON Struct's declared schema order, with no insignificant whitespace. +_Avoid_: happy path, normal JSON + +**Lazy Field**: +A JSON Struct field whose value is represented by its source range until first access. +_Avoid_: deferred property, lazy slot + +**Fresh Deserialization**: +Deserialization that creates a new JSON Struct. +_Avoid_: allocating parse + +**Reuse Deserialization**: +Deserialization that writes into a caller-provided JSON Struct while retaining reusable allocations where possible. +_Avoid_: in-place parse, cached parse diff --git a/assembly/__benches__/classic/canada.bench.ts b/assembly/__benches__/classic/canada.bench.ts index 7b2d1ff2..a6ddc3bf 100644 --- a/assembly/__benches__/classic/canada.bench.ts +++ b/assembly/__benches__/classic/canada.bench.ts @@ -3,6 +3,7 @@ import { expect } from "../../__tests__/lib"; import { blackbox, bench, + ChangingPayloads, dumpToFile, readFile, utf8ByteLength, @@ -40,6 +41,9 @@ const prettyJson = readFile( "./assembly/__benches__/payloads/canada.pretty.json", ); const minJson = readFile("./assembly/__benches__/payloads/canada.min.json"); +const freshPayloads = new ChangingPayloads(minJson); +const reusePayloads = new ChangingPayloads(minJson); +const reuseTarget = JSON.parse(minJson); expect(JSON.stringify(JSON.parse(prettyJson))).toBe(minJson); expect(JSON.stringify(JSON.parse(minJson))).toBe(minJson); @@ -60,12 +64,21 @@ dumpToFile("canada-pretty", "deserialize"); bench( "Deserialize Canada (min)", () => { - blackbox(JSON.parse(minJson)); + blackbox(JSON.parse(freshPayloads.next())); }, 500, utf8ByteLength(minJson), ); dumpToFile("canada-min", "deserialize"); +bench( + "Deserialize Canada (min, reuse)", + () => { + blackbox(JSON.parse(reusePayloads.next(), reuseTarget)); + }, + 500, + utf8ByteLength(minJson), +); +dumpToFile("canada-min-reuse", "deserialize"); bench( "Serialize Canada (min)", diff --git a/assembly/__benches__/classic/github_events.bench.ts b/assembly/__benches__/classic/github_events.bench.ts index cf7b46e9..686009b9 100644 --- a/assembly/__benches__/classic/github_events.bench.ts +++ b/assembly/__benches__/classic/github_events.bench.ts @@ -3,6 +3,7 @@ import { expect } from "../../__tests__/lib"; import { blackbox, bench, + ChangingPayloads, dumpToFile, readFile, utf8ByteLength, @@ -245,6 +246,9 @@ const prettyJson = readFile( const minJson = readFile( "./assembly/__benches__/payloads/github_events.min.json", ); +const freshPayloads = new ChangingPayloads(minJson); +const reusePayloads = new ChangingPayloads(minJson); +const reuseTarget = JSON.parse(minJson); expect(JSON.parse(minJson).length).toBe(30); @@ -264,12 +268,21 @@ dumpToFile("github_events-pretty", "deserialize"); bench( "Deserialize GitHubEvents (min)", () => { - blackbox(JSON.parse(minJson)); + blackbox(JSON.parse(freshPayloads.next())); }, 20000, utf8ByteLength(minJson), ); dumpToFile("github_events-min", "deserialize"); +bench( + "Deserialize GitHubEvents (min, reuse)", + () => { + blackbox(JSON.parse(reusePayloads.next(), reuseTarget)); + }, + 20000, + utf8ByteLength(minJson), +); +dumpToFile("github_events-min-reuse", "deserialize"); bench( "Serialize GitHubEvents (min)", diff --git a/assembly/__benches__/classic/twitter.bench.ts b/assembly/__benches__/classic/twitter.bench.ts index 4d3542ed..4156cece 100644 --- a/assembly/__benches__/classic/twitter.bench.ts +++ b/assembly/__benches__/classic/twitter.bench.ts @@ -3,6 +3,7 @@ import { expect } from "../../__tests__/lib"; import { blackbox, bench, + ChangingPayloads, dumpToFile, readFile, utf8ByteLength, @@ -246,6 +247,9 @@ const prettyJson = readFile( "./assembly/__benches__/payloads/twitter.pretty.json", ); const minJson = readFile("./assembly/__benches__/payloads/twitter.min.json"); +const freshPayloads = new ChangingPayloads(minJson); +const reusePayloads = new ChangingPayloads(minJson); +const reuseTarget = JSON.parse(minJson); const outStr = ""; expect(JSON.parse(minJson).statuses.length).toBe(100); @@ -265,12 +269,21 @@ dumpToFile("twitter-pretty", "deserialize"); bench( "Deserialize Twitter (min)", () => { - blackbox(JSON.parse(minJson)); + blackbox(JSON.parse(freshPayloads.next())); }, 2000, utf8ByteLength(minJson), ); dumpToFile("twitter-min", "deserialize"); +bench( + "Deserialize Twitter (min, reuse)", + () => { + blackbox(JSON.parse(reusePayloads.next(), reuseTarget)); + }, + 2000, + utf8ByteLength(minJson), +); +dumpToFile("twitter-min-reuse", "deserialize"); bench( "Serialize Twitter (min)", diff --git a/assembly/__benches__/large.bench.ts b/assembly/__benches__/large.bench.ts index 2794ea24..2d597dda 100644 --- a/assembly/__benches__/large.bench.ts +++ b/assembly/__benches__/large.bench.ts @@ -1,6 +1,12 @@ import { JSON } from ".."; import { expect } from "../__tests__/lib"; -import { bench, blackbox, dumpToFile, utf8ByteLength } from "./lib/bench"; +import { + bench, + blackbox, + ChangingPayloads, + dumpToFile, + utf8ByteLength, +} from "./lib/bench"; @json @@ -122,6 +128,9 @@ class Repo { // Create instances and assign fields directly const v2 = `{"id":132935648,"node_id":"MDEwOlJlcG9zaXRvcnkxMzI5MzU2NDg=","name":"boysenberry-repo-1","full_name":"octocat/boysenberry-repo-1","private":true,"owner":{"login":"octocat","id":583231,"node_id":"MDQ6VXNlcjU4MzIzMQ==","avatar_url":"https://avatars.githubusercontent.com/u/583231?v=4","gravatar_id":"","url":"https://api.github.com/users/octocat","html_url":"https://github.com/octocat","followers_url":"https://api.github.com/users/octocat/followers","following_url":"https://api.github.com/users/octocat/following{/other_user}","gists_url":"https://api.github.com/users/octocat/gists{/gist_id}","starred_url":"https://api.github.com/users/octocat/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/octocat/subscriptions","organizations_url":"https://api.github.com/users/octocat/orgs","repos_url":"https://api.github.com/users/octocat/repos","events_url":"https://api.github.com/users/octocat/events{/privacy}","received_events_url":"https://api.github.com/users/octocat/received_events","type":"User","user_view_type":"public","site_admin":false},"html_url":"https://github.com/octocat/boysenberry-repo-1","description":"Testing","fork":true,"url":"https://api.github.com/repos/octocat/boysenberry-repo-1","forks_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/forks","keys_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/keys{/key_id}","collaborators_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/teams","hooks_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/hooks","issue_events_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/issues/events{/number}","events_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/events","assignees_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/assignees{/user}","branches_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/branches{/branch}","tags_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/tags","blobs_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/git/refs{/sha}","trees_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/git/trees{/sha}","statuses_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/statuses/{sha}","languages_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/languages","stargazers_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/stargazers","contributors_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/contributors","subscribers_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/subscribers","subscription_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/subscription","commits_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/commits{/sha}","git_commits_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/git/commits{/sha}","comments_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/comments{/number}","issue_comment_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/issues/comments{/number}","contents_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/contents/{+path}","compare_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/compare/{base}...{head}","merges_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/merges","archive_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/downloads","issues_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/issues{/number}","pulls_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/pulls{/number}","milestones_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/milestones{/number}","notifications_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/labels{/name}","releases_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/releases{/id}","deployments_url":"https://api.github.com/repos/octocat/boysenberry-repo-1/deployments","created_at":"2018-05-10T17:51:29Z","updated_at":"2025-05-24T02:01:19Z","pushed_at":"2024-05-26T07:02:05Z","git_url":"git://github.com/octocat/boysenberry-repo-1.git","ssh_url":"git@github.com:octocat/boysenberry-repo-1.git","clone_url":"https://github.com/octocat/boysenberry-repo-1.git","svn_url":"https://github.com/octocat/boysenberry-repo-1","homepage":"","size":4,"stargazers_count":332,"watchers_count":332,"language":null,"has_issues":false,"has_projects":true,"has_downloads":true,"has_wiki":true,"has_pages":false,"has_discussions":false,"forks_count":20,"mirror_url":null,"archived":false,"disabled":false,"open_issues_count":1,"license":null,"allow_forking":true,"is_template":false,"web_commit_signoff_required":false,"topics":[],"visibility":"public","forks":20,"open_issues":1,"watchers":332,"default_branch":"master"}`; const v1 = JSON.parse(v2); +const freshPayloads = new ChangingPayloads(v2); +const reusePayloads = new ChangingPayloads(v2); +const reuseTarget = JSON.parse(v2); const byteLength: usize = utf8ByteLength(v2); expect(JSON.stringify(JSON.parse(v2))).toBe(v2); bench( @@ -136,12 +145,21 @@ dumpToFile("large", "serialize"); bench( "Deserialize Large API Response", () => { - blackbox(JSON.parse(v2)); + blackbox(JSON.parse(freshPayloads.next())); }, 10_000, byteLength, ); dumpToFile("large", "deserialize"); +bench( + "Deserialize Large API Response (reuse)", + () => { + blackbox(JSON.parse(reusePayloads.next(), reuseTarget)); + }, + 10_000, + byteLength, +); +dumpToFile("large-reuse", "deserialize"); // Dynamic JSON.Obj variant of the same payload (typed struct vs JSON.Obj). const objLarge = JSON.parse(v2); diff --git a/assembly/__benches__/medium.bench.ts b/assembly/__benches__/medium.bench.ts index 6cc4922d..64d7e71e 100644 --- a/assembly/__benches__/medium.bench.ts +++ b/assembly/__benches__/medium.bench.ts @@ -1,6 +1,12 @@ import { JSON } from ".."; import { expect } from "../__tests__/lib"; -import { bench, blackbox, dumpToFile, utf8ByteLength } from "./lib/bench"; +import { + bench, + blackbox, + ChangingPayloads, + dumpToFile, + utf8ByteLength, +} from "./lib/bench"; @json @@ -44,6 +50,9 @@ class MediumAPIResponse { const v2 = `{"id":42,"username":"jairus","full_name":"Jairus Tanaka","email":"me@jairus.dev","avatar_url":"https://avatars.githubusercontent.com/u/123456?v=4","bio":"I like compilers, elegant algorithms, bare metal, simd, and wasm.","website":"https://jairus.dev/","location":"Seattle, WA","joined_at":"2020-01-15T08:30:00Z","is_verified":true,"is_premium":true,"follower_count":61,"following_count":39,"preferences":{"theme":"dark","notifications":true,"language":"en-US","timezone":"America/Los_Angeles","privacy_level":"friends_only","two_factor_enabled":false},"tags":["typescript","webassembly","performance","rust","assemblyscript","json"],"recent_activity":[{"action":"starred","timestamp":"2025-12-22T10:15:00Z","target":"assemblyscript/json-as"},{"action":"commented","timestamp":"2025-12-22T09:42:00Z","target":"issue #142"},{"action":"pushed","timestamp":"2025-12-21T23:58:00Z","target":"main branch"},{"action":"forked","timestamp":"2025-12-21T18:20:00Z","target":"fast-json-wasm"},{"action":"created","timestamp":"2025-12-21T14:10:00Z","target":"new benchmark suite"}]}`; const v1 = JSON.parse(v2); +const freshPayloads = new ChangingPayloads(v2); +const reusePayloads = new ChangingPayloads(v2); +const reuseTarget = JSON.parse(v2); const byteLength: usize = utf8ByteLength(v2); expect(JSON.stringify(JSON.parse(v2))).toBe(v2); bench( @@ -58,12 +67,21 @@ dumpToFile("medium", "serialize"); bench( "Deserialize Medium API Response", () => { - blackbox(JSON.parse(v2)); + blackbox(JSON.parse(freshPayloads.next())); }, 500_000, byteLength, ); dumpToFile("medium", "deserialize"); +bench( + "Deserialize Medium API Response (reuse)", + () => { + blackbox(JSON.parse(reusePayloads.next(), reuseTarget)); + }, + 500_000, + byteLength, +); +dumpToFile("medium-reuse", "deserialize"); // Dynamic JSON.Obj variant of the same payload (typed struct vs JSON.Obj). const objMedium = JSON.parse(v2); diff --git a/assembly/__benches__/small.bench.ts b/assembly/__benches__/small.bench.ts index 0a09b93b..63b1b210 100644 --- a/assembly/__benches__/small.bench.ts +++ b/assembly/__benches__/small.bench.ts @@ -1,6 +1,12 @@ import { JSON } from ".."; import { expect } from "../__tests__/lib"; -import { bench, blackbox, dumpToFile, utf8ByteLength } from "./lib/bench"; +import { + bench, + blackbox, + ChangingPayloads, + dumpToFile, + utf8ByteLength, +} from "./lib/bench"; @json @@ -11,6 +17,9 @@ class SmallJSON { } const v2 = `{"id":1,"name":"Small Object","active":true}`; const v1 = JSON.parse(v2); +const freshPayloads = new ChangingPayloads(v2); +const reusePayloads = new ChangingPayloads(v2); +const reuseTarget = JSON.parse(v2); const byteLength: usize = utf8ByteLength(v2); expect(JSON.stringify(JSON.parse(v2))).toBe(v2); bench( @@ -25,12 +34,21 @@ dumpToFile("small", "serialize"); bench( "Deserialize Small Object", () => { - blackbox(JSON.parse(v2)); + blackbox(JSON.parse(freshPayloads.next())); }, 5_000_000, byteLength, ); dumpToFile("small", "deserialize"); +bench( + "Deserialize Small Object (reuse)", + () => { + blackbox(JSON.parse(reusePayloads.next(), reuseTarget)); + }, + 5_000_000, + byteLength, +); +dumpToFile("small-reuse", "deserialize"); // Dynamic JSON.Obj variant of the same payload (typed struct vs JSON.Obj). const objSmall = JSON.parse(v2); diff --git a/assembly/deserialize/parseMode.ts b/assembly/deserialize/parseMode.ts index 0d72f6eb..90aa8c80 100644 --- a/assembly/deserialize/parseMode.ts +++ b/assembly/deserialize/parseMode.ts @@ -10,6 +10,7 @@ const ENABLE_EXACT_SOURCE_TRACES = true; let STRING_TRACE_DEPTH = 0; let STRING_TRACE_ACTIVE = false; let STRING_TRACE_COMPLETE = false; +let STRING_TRACE_ADMITTED = false; let STRING_TRACE_SOURCE: string | null = null; let STRING_TRACE_OUT: usize = 0; let STRING_TRACE_TYPE: u32 = 0; @@ -54,11 +55,25 @@ export function beginStringFieldTrace( const sourcePtr = changetype(source); const sameRoot = + STRING_TRACE_ADMITTED && STRING_TRACE_COMPLETE && STRING_TRACE_OUT == out && STRING_TRACE_TYPE == typeId && changetype(STRING_TRACE_SOURCE) == sourcePtr; if (!sameRoot) { + const repeatedCandidate = + !STRING_TRACE_ADMITTED && + STRING_TRACE_OUT == out && + STRING_TRACE_TYPE == typeId && + changetype(STRING_TRACE_SOURCE) == sourcePtr; + if (repeatedCandidate) { + // Admit a trace only after the same immutable source/output pair repeats. + // Request-style workloads that cycle through changing payloads otherwise + // pay to record and clear every field without ever producing a cache hit. + STRING_TRACE_ADMITTED = true; + } else { + STRING_TRACE_ADMITTED = false; + } STRING_TRACE_SOURCE = source; STRING_TRACE_OUT = out; STRING_TRACE_TYPE = typeId; @@ -74,6 +89,11 @@ export function beginStringFieldTrace( OBJECT_TRACE_MASKS.length = 0; OBJECT_TRACE_TIERS.length = 0; OBJECT_TRACE_SEPARATORS.length = 0; + if (!STRING_TRACE_ADMITTED) { + STRING_TRACE_COMPLETE = false; + STRING_TRACE_ACTIVE = false; + return; + } } STRING_TRACE_INDEX = 0; OBJECT_TRACE_INDEX = 0; @@ -85,7 +105,7 @@ export function beginStringFieldTrace( @inline export function endStringFieldTrace(success: bool): void { if (!ENABLE_EXACT_SOURCE_TRACES) return; - if (STRING_TRACE_DEPTH == 1) { + if (STRING_TRACE_DEPTH == 1 && STRING_TRACE_ACTIVE) { STRING_TRACE_ACTIVE = false; STRING_TRACE_COMPLETE = success; } From 99e1222e57a3bacf7c4c9d41c77e436c1f6c653e Mon Sep 17 00:00:00 2001 From: Jairus Tanaka Date: Thu, 23 Jul 2026 19:46:08 -0700 Subject: [PATCH 2/4] perf: pack SIMD string scans and coordinate pairs --- assembly/deserialize/simd/string.ts | 77 ++++++++++++++++++++++++ assembly/deserialize/swar/array/float.ts | 22 ++++--- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/assembly/deserialize/simd/string.ts b/assembly/deserialize/simd/string.ts index 31bb51c8..b9ef9a34 100644 --- a/assembly/deserialize/simd/string.ts +++ b/assembly/deserialize/simd/string.ts @@ -13,6 +13,10 @@ import { probeStringFieldTrace, recordStringFieldTrace } from "../parseMode"; @lazy const SPLAT_5C = i16x8.splat(0x5c); // \ // @ts-expect-error: @lazy is a valid decorator @lazy const SPLAT_22 = i16x8.splat(0x22); // " +// @ts-expect-error: @lazy is a valid decorator +@lazy const SPLAT_5C_I8 = i8x16.splat(0x5c); // \ +// @ts-expect-error: @lazy is a valid decorator +@lazy const SPLAT_22_I8 = i8x16.splat(0x22); // " // Overflow Pattern for Unicode Escapes (READ) // \u0001 0 \u0001__| + 0 @@ -438,9 +442,82 @@ export function deserializeStringFieldTrusted_SIMD( const dstFieldPtr = dstObj + dstOffset; const cachedEnd = probeStringFieldTrace(dstFieldPtr, payloadStart); if (cachedEnd != 0) return cachedEnd; + const srcEnd32 = srcEnd - 32; const srcEnd16 = srcEnd - 16; let srcStart = payloadStart; + // Most JSON strings end in their first eight code units. Keep that case on + // the smaller single-load path and use packed scanning only for longer runs. + if (srcStart <= srcEnd16) { + const block = load(srcStart); + const mask = i16x8.bitmask( + v128.or(i16x8.eq(block, SPLAT_5C), i16x8.eq(block, SPLAT_22)), + ); + if (mask != 0) { + const laneIdx = usize(ctz(mask) << 1); + const srcIdx = srcStart + laneIdx; + const char = load(srcIdx); + if (char == QUOTE) { + writeStringToField_SIMD( + dstFieldPtr, + payloadStart, + (srcIdx - payloadStart), + ); + const next = srcIdx + 2; + recordStringFieldTrace(dstFieldPtr, payloadStart, next); + return next; + } + const next = deserializeEscapedStringField_SIMD( + payloadStart, + srcIdx, + srcEnd, + dstFieldPtr, + ); + if (next != 0) recordStringFieldTrace(dstFieldPtr, payloadStart, next); + return next; + } + srcStart += 16; + } + + // Narrow two UTF-16 vectors into one byte vector. Unsigned narrowing + // saturates non-ASCII code units to 0xff, so they cannot alias either JSON + // delimiter and do not require a separate ASCII-classification pass. + while (srcStart <= srcEnd32) { + const packed = i8x16.narrow_i16x8_u( + load(srcStart), + load(srcStart, 16), + ); + const mask = i8x16.bitmask( + v128.or(i8x16.eq(packed, SPLAT_5C_I8), i8x16.eq(packed, SPLAT_22_I8)), + ); + if (mask == 0) { + srcStart += 32; + continue; + } + + const laneIdx = usize(ctz(mask) << 1); + const srcIdx = srcStart + laneIdx; + const char = load(srcIdx); + if (char == QUOTE) { + writeStringToField_SIMD( + dstFieldPtr, + payloadStart, + (srcIdx - payloadStart), + ); + const next = srcIdx + 2; + recordStringFieldTrace(dstFieldPtr, payloadStart, next); + return next; + } + const next = deserializeEscapedStringField_SIMD( + payloadStart, + srcIdx, + srcEnd, + dstFieldPtr, + ); + if (next != 0) recordStringFieldTrace(dstFieldPtr, payloadStart, next); + return next; + } + while (srcStart <= srcEnd16) { const block = load(srcStart); const mask = i16x8.bitmask( diff --git a/assembly/deserialize/swar/array/float.ts b/assembly/deserialize/swar/array/float.ts index 4e4bd788..64d05278 100644 --- a/assembly/deserialize/swar/array/float.ts +++ b/assembly/deserialize/swar/array/float.ts @@ -603,22 +603,27 @@ export function deserializeFloatArrayBody( return srcStart + 2; } - // GeoJSON's innermost coordinate arrays are overwhelmingly reused - // `[longitude,latitude]` pairs. Unroll that stable-shape case so neither - // element pays the generic index/capacity selection or loop backedge. A - // mismatch restores the first-value cursor and falls through unchanged. - if (ASC_FEATURE_SIMD && reusableLength == 2) { + // GeoJSON's innermost coordinate arrays are overwhelmingly + // `[longitude,latitude]` pairs. Fresh arrays are sized once before the + // attempt; reused arrays write into their existing two slots. A mismatch + // restores the cursor (and fresh logical length) before the generic loop. + if (ASC_FEATURE_SIMD && (reusableLength == 0 || reusableLength == 2)) { const firstStart = srcStart; + let pairDataStart = reusableDataStart; + if (reusableLength == 0) { + out.length = 2; + pairDataStart = out.dataStart; + } const pairEnd = parseFixed14Pair>( srcStart, srcEnd, - reusableDataStart, + pairDataStart, ); if (pairEnd) return pairEnd; let next = parseFloatElementSWAR>( srcStart, srcEnd, - reusableDataStart, + pairDataStart, ); if (next) { srcStart = next; @@ -634,7 +639,7 @@ export function deserializeFloatArrayBody( next = parseFloatElementSWAR>( srcStart, srcEnd, - reusableDataStart + elementSize, + pairDataStart + elementSize, ); if (next) { srcStart = next; @@ -646,6 +651,7 @@ export function deserializeFloatArrayBody( } } srcStart = firstStart; + if (reusableLength == 0) out.length = 0; } while (srcStart < srcEnd) { From f8f9f7766f98d1c6cb67deb3a56e4b571c719d8d Mon Sep 17 00:00:00 2001 From: Jairus Tanaka Date: Thu, 23 Jul 2026 20:24:14 -0700 Subject: [PATCH 3/4] perf: right-size GeoJSON coordinate pairs --- assembly/deserialize/swar/array/array.ts | 4 ++-- assembly/deserialize/swar/array/float.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/assembly/deserialize/swar/array/array.ts b/assembly/deserialize/swar/array/array.ts index 8c3af55d..d3e89b87 100644 --- a/assembly/deserialize/swar/array/array.ts +++ b/assembly/deserialize/swar/array/array.ts @@ -1,6 +1,6 @@ import { JSON } from "../../.."; import { BRACKET_LEFT, BRACKET_RIGHT, COMMA } from "../../../custom/chars"; -import { deserializeFloatArrayBody } from "./float"; +import { createExactFloatPair, deserializeFloatArrayBody } from "./float"; import { ensureArrayField, scanValueEnd, skipWhitespace } from "./shared"; import { skipPrettyWhitespace_SIMD } from "../../../util/prettyWhitespaceSimd"; @@ -59,7 +59,7 @@ export function deserializeArrayArrayBody( reusableDataStart + index * elementSize, ); } else { - value = changetype>(instantiate>()); + value = createExactFloatPair>(); out.push(value); } srcStart = deserializeFloatArrayBody>( diff --git a/assembly/deserialize/swar/array/float.ts b/assembly/deserialize/swar/array/float.ts index 64d05278..c151f922 100644 --- a/assembly/deserialize/swar/array/float.ts +++ b/assembly/deserialize/swar/array/float.ts @@ -22,6 +22,21 @@ function skipFloatArrayWhitespace(srcStart: usize, srcEnd: usize): usize { return srcStart; } +// AssemblyScript's regular Array constructor reserves at least eight slots. +// GeoJSON coordinate pairs need exactly two, so construct the standard Array +// layout directly and avoid 6 unused f64/f32 slots per fresh pair. +export function createExactFloatPair(): T { + const byteLength = usize(2 * sizeof>()); + const buffer = __new(byteLength, idof()); + const out = __new(offsetof(), idof()); + store(out, buffer, offsetof("buffer")); + store(out, buffer, offsetof("dataStart")); + store(out, byteLength, offsetof("byteLength")); + store(out, 2, offsetof("length_")); + __link(out, buffer, false); + return changetype(out); +} + function fallbackStore(origStart: usize, end: usize, slot: usize): void { const s = ptrToStr(origStart, end); if (sizeof() == sizeof()) { From ffa426f5296e03ef51b98f320bd73fd86b29e20a Mon Sep 17 00:00:00 2001 From: Jairus Tanaka Date: Mon, 3 Aug 2026 20:59:53 -0700 Subject: [PATCH 4/4] perf: add wide SIMD string paths --- README.md | 9 + SIMDJSON_NOTES.md | 391 ++++++++++++++++++++++++ assembly/__tests__/wago-wide.fixture.ts | 121 ++++++++ assembly/deserialize/simd/string.ts | 65 ++++ assembly/index.d.ts | 17 ++ assembly/index.ts | 10 + assembly/serialize/simd/string.ts | 124 ++++++++ assembly/util/wideSimd.ts | 142 +++++++++ package.json | 2 + scripts/bench-wago-classic.mjs | 331 ++++++++++++++++++++ scripts/test-wago-wide.mjs | 288 +++++++++++++++++ transform/lib/index.js | 18 ++ transform/src/index.ts | 26 ++ 13 files changed, 1544 insertions(+) create mode 100644 SIMDJSON_NOTES.md create mode 100644 assembly/__tests__/wago-wide.fixture.ts create mode 100644 assembly/util/wideSimd.ts create mode 100644 scripts/bench-wago-classic.mjs create mode 100644 scripts/test-wago-wide.mjs diff --git a/README.md b/README.md index ed9bdd4a..88384145 100644 --- a/README.md +++ b/README.md @@ -681,6 +681,15 @@ Here's a short list: **JSON_MODE** (default: SWAR) - Selects which mode should be used. Can be `NAIVE,SWAR,SIMD`. Note that `--enable simd` may be required. +**JSON_SIMD_WIDTH** (default: 128) - Selects 128-, 256-, or 512-bit string scans in SIMD mode. The 256/512 paths use [`as-simd`](https://github.com/JairusSW/as-simd) fused memory predicates, while general wide operations use the portable externref ABI consumed by Wago's [`JairusSW/wide`](https://github.com/JairusSW/wide) plugin: + +```bash +WAGO_PLUGINS=wide JSON_MODE=SIMD JSON_SIMD_WIDTH=512 \ + asc app.ts --transform json-as --transform as-simd --enable simd +``` + +Run `npm run test:wago-wide` to compile and execute both native-width variants against current Wago and Wide. `JSON_SIMD_WIDTH=256/512` without `--enable simd` is rejected at compile time. + **JSON_USE_FAST_PATH** (default: 1) - The transform emits the fast `__DESERIALIZE` implementation for generated structs by default. Set to `0`, `false`, `off`, or `no` to force slow-path-only output. See [FAST_PATH_DESERIALIZE.md](./FAST_PATH_DESERIALIZE.md) for the current support matrix, known gaps, and dedicated test command. **JSON_WRITE** (default: "") - Select a series of files to output after transform and optimization passes have completed for easy inspection. Usage: `JSON_WRITE=.path-to-file-a.ts,./path-to-file-b.ts` diff --git a/SIMDJSON_NOTES.md b/SIMDJSON_NOTES.md new file mode 100644 index 00000000..7b441d19 --- /dev/null +++ b/SIMDJSON_NOTES.md @@ -0,0 +1,391 @@ +# What `json-as` / `as-simd` can learn from simdjson + +Upstream examined: simdjson commit +[`8e6bac94877f2d3d026000d36ce81e0aaf38d26f`](https://github.com/simdjson/simdjson/tree/8e6bac94877f2d3d026000d36ce81e0aaf38d26f). +This note focuses on the Haswell (AVX2) and Ice Lake (AVX-512) kernels and on +ideas applicable to UTF-16 AssemblyScript strings. It does not imply that +simdjson's UTF-8 parser can be copied directly. + +## Executive summary + +The most useful lesson is not “make every loop as wide as possible.” +simdjson keeps a **fixed 64-byte logical block** across architectures: + +- AVX2 represents it as two 32-byte vectors. +- AVX-512 represents it as one 64-byte vector. +- Both return the same scalar `uint64_t` position mask to the higher-level + algorithm. + +See `simd8x64` in +[`include/simdjson/haswell/simd.h:298-365`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/haswell/simd.h#L298-L365) +and +[`include/simdjson/icelake/simd.h:320-368`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/icelake/simd.h#L320-L368). + +For `json-as`, a 64-byte block is 32 UTF-16 code units. That gives a convenient +architecture-neutral `u32` lane mask. v256 can produce it from two 16-lane +pieces; v512 can produce it from one 32-lane operation. The parsing algorithm +should consume this common abstraction rather than changing its semantics and +loop shape with `JSON_SIMD_WIDTH`. + +The highest-value concrete change is a fused `as-simd` operation that: + +1. loads a 64-byte logical block directly from Wasm memory; +2. optionally stores that same block directly to the destination; +3. compares UTF-16 lanes against all relevant characters in the same lowering; +4. returns compact scalar masks without writing a wide temporary back to Wasm + memory. + +For deserialization it should return **separate quote and backslash masks**. +For serialization it should return one 32-bit “needs escaping or surrogate +handling” lane mask. This copies simdjson's actual seam: vector work stays in +the architecture kernel; control flow operates on cheap scalar bitsets. + +## 1. Keep the logical block fixed at 64 bytes + +The AVX2 `simd8x64` loads two vectors and joins their 32-bit movemasks into one +64-bit result +([`include/simdjson/haswell/simd.h:309-329`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/haswell/simd.h#L309-L329)). +Its `eq` and `lteq` helpers hide those two comparisons behind one 64-byte API +([`include/simdjson/haswell/simd.h:343-364`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/haswell/simd.h#L343-L364)). +The AVX-512 version performs the same operations with one vector and directly +returns a 64-bit mask +([`include/simdjson/icelake/simd.h:355-367`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/icelake/simd.h#L355-L367)). + +### Transferable + +- Add a `block64` layer to `as-simd` or `json-as`, with operations expressed in + terms of 32 UTF-16 lanes rather than native vector width. +- Make v256 lower a block to two wide operations and concatenate their masks. +- Make v512 lower it to one wide operation when the host really supports that + efficiently. +- Keep v128 as four chunks under the same interface, which makes correctness + tests and benchmarks directly comparable. +- Use a `u32` mask for UTF-16 lanes. Only use a `u64` when returning two packed + `u32` masks. + +This avoids width-specific duplicated loops and makes v512's win come from +fewer loads/comparisons, rather than from extra cross-boundary plumbing. + +## 2. Fuse copy, classification, and mask extraction + +simdjson's string parser loads a block once, stores it to the output +unconditionally, and derives quote/backslash masks from the loaded value: + +- AVX2 processes 32 bytes and calls movemask on each comparison + ([`include/simdjson/haswell/stringparsing_defs.h:31-41`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/haswell/stringparsing_defs.h#L31-L41)). +- AVX-512 processes 64 bytes and gets the two masks directly from compare-mask + instructions + ([`include/simdjson/icelake/stringparsing_defs.h:31-41`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/icelake/stringparsing_defs.h#L31-L41)). + +The generic parser then asks which exceptional character comes first. If there +is none, it advances by the complete block. If one exists, the already-written +plain prefix is retained and only the exceptional character is fixed up +([`src/generic/stage2/stringparsing.h:151-192`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage2/stringparsing.h#L151-L192)). + +### Transferable + +Add operations shaped approximately like: + +```text +copy_find_quote_backslash_utf16_64(src, dst) -> u64 + low 32 bits: backslash lanes + high 32 bits: quote lanes + +copy_find_json_escape_utf16_64(src, dst) -> u32 + one bit per lane for quote, backslash, control, or surrogate + +find_backslash_utf16_64(src) -> u32 +``` + +These should be single `as-simd`/Wide boundary crossings. The lowering should +load once and return scalar masks directly. It should not: + +- copy through a wide register file in linear memory; +- materialize vector comparison results back into linear memory; +- invoke separate wide operations for load, compare, bitmask, and store; +- use a byte mask when the caller ultimately reasons about UTF-16 lanes. + +For `serializeString_SIMD`, fusing the current `wideStringEscapeMask` and +`copyWide` calls is the first priority. For escaped deserialization, use the +copy-and-find form so the clean prefix is already in the destination. For the +whole-value “no escapes” probe, use find-only because `json-as` can allocate and +copy the complete string once after proving it clean. + +### Why separate quote and backslash masks matter + +simdjson decides which appears first without two branches or two `ctz` +operations: + +```text +quote is first: ((backslashes - 1) & quotes) != 0 +backslash is first: ((quotes - 1) & backslashes) != 0 +``` + +See +[`include/simdjson/icelake/stringparsing_defs.h:22-25`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/icelake/stringparsing_defs.h#L22-L25). +The same scalar trick works on packed UTF-16 lane masks after unpacking the two +`u32`s. + +An “equals either” mask remains useful when only the first exceptional lane is +needed, but it loses information needed to distinguish a terminating quote from +an escape. Do not make it the only API. + +## 3. Return masks, not wide booleans + +On AVX2, equality produces byte vectors and `_mm256_movemask_epi8` reduces each +to a scalar mask +([`include/simdjson/haswell/simd.h:53-74`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/haswell/simd.h#L53-L74)). +On AVX-512, equality itself returns the scalar mask-register value +([`include/simdjson/icelake/simd.h:69-79`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/icelake/simd.h#L69-L79)). + +The higher layers almost exclusively use scalar mask algebra: + +- `ctz(mask)` finds the next interesting lane; +- `mask &= mask - 1` clears it; +- subtraction computes backslash-run parity; +- prefix XOR computes quote state. + +This is especially relevant to Wago/Wide: an imported operation returning a +scalar mask is a better ABI for scanning than an operation returning a wide +boolean carrier that must later be stored and reduced by Wasm code. + +### Recommended `as-simd` primitive set + +Prioritize use-case primitives over a large set of tiny register-file +operations: + +1. `eq_u16x32_mask(ptr, splat) -> u32` +2. `eq2_u16x32_masks(ptr, a, b) -> u64` +3. `json_escape_u16x32_mask(ptr) -> u32` +4. copy variants of 2 and 3 +5. only then general wide comparison/carrier APIs + +The fused operations are deliberately “deep” APIs: their contracts are stable +across AVX2 and AVX-512 even though their lowering is not. + +## 4. Use one lane bit for serializer exceptions + +simdjson's native serializer-style scan combines quote, backslash, and control +tests before extracting one mask: + +- AVX2 ORs three boolean vectors then performs one movemask + ([`include/simdjson/haswell/stringparsing_defs.h:57-66`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/haswell/stringparsing_defs.h#L57-L66)). +- AVX-512 ORs three mask-register results + ([`include/simdjson/icelake/stringparsing_defs.h:58-67`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/icelake/stringparsing_defs.h#L58-L67)). + +`json-as` additionally needs to detect UTF-16 surrogate code units. Its wide +primitive should directly compute: + +```text +lane == '"' +OR lane == '\\' +OR lane < 0x20 +OR (lane >= 0xD800 AND lane <= 0xDFFF) +``` + +and return one bit per UTF-16 lane. A one-bit-per-byte mask makes the caller +reconstruct lane identity and embeds an x86/Wasm byte-movemask artifact into +the public API. The lane-mask form also makes `ctz(mask) * 2` the only position +conversion. + +The surrogate test is specific to `json-as`; simdjson validates UTF-8 and +therefore has no corresponding UTF-16 check. + +## 5. Do mask-level backslash and quote processing across blocks + +simdjson finds backslashes and quotes for a whole 64-byte block, computes which +characters are escaped, removes escaped quotes, and derives the in-string mask: + +[`src/generic/stage1/json_string_scanner.h:62-84`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage1/json_string_scanner.h#L62-L84). + +Runs of backslashes are resolved with scalar subtraction and an alternating-bit +constant, including a one-bit carry from the previous block: + +[`src/generic/stage1/json_escape_scanner.h:50-70`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage1/json_escape_scanner.h#L50-L70) +and +[`src/generic/stage1/json_escape_scanner.h:96-142`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage1/json_escape_scanner.h#L96-L142). + +### Transferable, but only where `json-as` scans quoted source + +For object-field parsing or any path that must locate an unescaped closing +quote, scan quote and backslash masks together and carry trailing-backslash +state between blocks. This avoids dropping to scalar code merely because a +block contains a backslash after an earlier clean prefix. + +For the standalone path where quotes are already stripped and only escape +decoding remains, the full quote-prefix-XOR machinery is unnecessary. Retain +the simpler backslash-only mask. + +The exact simdjson `u64` constants assume one bit per byte. For UTF-16 lane +masks, use a 32-bit alternating pattern (`0xAAAAAAAA`) and verify cross-block +backslash runs with exhaustive tests. + +## 6. Batch two logical blocks to hide latency + +simdjson's stage 1 works on two independent 64-byte inputs per 128-byte step. +The comments explain that loads and vector classification can overlap, while +the string-state portion remains serial +([`src/generic/stage1/json_structural_indexer.h:176-191`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage1/json_structural_indexer.h#L176-L191)). +The implementation loads both blocks and scans both before emitting their +results +([`src/generic/stage1/json_structural_indexer.h:220-237`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage1/json_structural_indexer.h#L220-L237)). + +### Transferable after the fused primitive exists + +Benchmark an unrolled 128-byte loop: + +1. call the 64-byte classifier for block A; +2. call it for block B; +3. consume A's mask; +4. consume B's mask. + +For clean serialization, both blocks can be classified/copied independently. +For quoted-source parsing, mask generation can overlap but escape/quote carry +must be applied in order. Do not unroll the current multi-import/register-file +path first; that is likely to multiply overhead rather than hide it. + +## 7. Preserve an ASCII/clean fast path + +simdjson's UTF-8 checker first asks whether the entire 64-byte block is ASCII. +If so, it skips the expensive lookup-based validation, while still carrying an +incomplete-sequence error from the prior block +([`src/generic/stage1/utf8_lookup4_algorithm.h:173-195`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage1/utf8_lookup4_algorithm.h#L173-L195)). + +### Transferable analogue + +AssemblyScript strings are UTF-16, so simdjson's UTF-8 validator is not directly +applicable. The analogous fast path is: + +- no quote/backslash/control/surrogate bits: copy or advance an entire block; +- any exception bit: locate only the first exception and enter the existing + scalar repair path. + +Do not port simdjson's three lookup tables, byte-history alignment, or +continuation checks into `json-as`; those validate UTF-8 byte streams +([`src/generic/stage1/utf8_lookup4_algorithm.h:16-113`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage1/utf8_lookup4_algorithm.h#L16-L113)). +They are relevant only if `json-as` later parses UTF-8 memory directly instead +of an AssemblyScript UTF-16 string. + +## 8. Tail safety and padding are part of the design + +simdjson normally requires 64 bytes of readable padding so its kernels can +perform full-width loads near the end +([`doc/performance.md:196-211`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/doc/performance.md#L196-L211)). +Its bounds-safe string parser instead switches to a space-padded scratch buffer +near the end +([`src/generic/stage2/stringparsing.h:196-264`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage2/stringparsing.h#L196-L264)). +The generic block reader also pads the final block with spaces +([`src/generic/stage1/buf_block_reader.h:82-109`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/generic/stage1/buf_block_reader.h#L82-L109)). + +### Transferable + +- Process a wide block when `src + 64 <= end`; equality is safe and should not + unnecessarily require one extra UTF-16 code unit. +- Use v128/scalar code for the tail unless a measured scratch-buffer approach + wins. +- Do not assume an AssemblyScript string allocation has 63 readable bytes after + its logical end. +- Keep explicit output slack if using unconditional full-block stores, and + document exactly how much can be overwritten. + +Padding-based overreads are a native C++ technique, not automatically safe in +Wasm linear memory or across managed-object boundaries. + +## 9. Width selection must be capability- and cost-based + +simdjson compiles distinct kernels and selects the first implementation whose +complete instruction requirements are supported +([`src/implementation.cpp:286-293`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/src/implementation.cpp#L286-L293)). +The AVX2 kernel declares AVX2, PCLMUL, BMI1, and BMI2; the AVX-512 kernel +declares a much larger set including BW, VL, and VBMI2 +([`include/simdjson/haswell/implementation.h:17-23`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/haswell/implementation.h#L17-L23), +[`include/simdjson/icelake/implementation.h:17-23`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/icelake/implementation.h#L17-L23)). + +It also treats instruction cost as architecture-specific. For example, its +AVX-512 compression deliberately avoids `mask_compressstoreu` because that +instruction performs badly on AMD Zen 4 +([`include/simdjson/icelake/simd.h:148-162`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/include/simdjson/icelake/simd.h#L148-L162)). +The documentation notes that older CPUs can downclock for wide instructions +and allows AVX-512 to be disabled +([`doc/performance.md:177-193`](https://github.com/simdjson/simdjson/blob/8e6bac94877f2d3d026000d36ce81e0aaf38d26f/doc/performance.md#L177-L193)). + +### Transferable + +- Treat `JSON_SIMD_WIDTH` as an override/testing knob, not proof that a width is + faster. +- Let Wago/Wide select a lowering based on the actual operation and host. +- Permit a 512-bit logical operation to lower to two AVX2 halves when that is + cheaper or AVX-512 is unavailable. +- Benchmark by CPU family, string length, escape density, ASCII/non-ASCII mix, + and allocation mode before changing the default. +- Keep v128 available as a cheap short-input path. The dispatch threshold should + account for import/call overhead as well as bytes per instruction. + +### Native-only details + +The following do not transfer directly through portable Wasm SIMD: + +- AVX-512 `k` mask registers and `_mm512_*_mask` intrinsics; +- CPUID-based native dispatch inside the Wasm module; +- AVX-512 byte compression instructions; +- safe native overreads based on page allocation; +- assumptions about x86 instruction latency, ports, or frequency behavior. + +They can only be exploited inside Wago/Wide's native lowering. The Wasm-facing +API should expose semantic operations and scalar results, not these mechanisms. + +## Prioritized implementation plan + +### P0 — reduce the wide boundary to one operation per block + +1. Define a 64-byte/32-lane UTF-16 block contract. +2. Add `eq2_u16x32_masks`, `json_escape_u16x32_mask`, and fused copy variants + to `as-simd`. +3. Lower them directly in Wide with scalar mask results. +4. Replace `wideStringEscapeMask` + `copyWide` in serialization with one call. +5. Return one bit per UTF-16 lane. + +Expected benefit: eliminates the remaining duplicated loads, boundary +crossings, and byte-mask repair work. This is the clearest lesson from both +simdjson x86 kernels. + +### P1 — use both masks and stay vectorized longer + +1. Return quote and backslash masks together for field/string parsing. +2. Use scalar first-character tests and `ctz` on the masks. +3. Add cross-block backslash parity so escaped quotes do not force premature + scalar scanning. +4. In escaped output paths, use unconditional wide copy then repair at the first + exceptional lane. + +Expected benefit: helps medium/long strings containing sparse escapes, where a +“break on any exception and restart scalar” strategy leaves most available +parallelism unused. + +### P2 — overlap two 64-byte blocks + +Unroll clean scanning/copying to 128 bytes and consume two masks per iteration. +Keep stateful mask processing ordered. Only do this after P0, and retain it only +if benchmarks show a win. + +### P3 — dispatch and thresholds + +Build a benchmark matrix for: + +- 0–64, 65–256, 257–4096, and large strings; +- no escapes, sparse escapes, dense escapes; +- ASCII, BMP non-ASCII, valid surrogate pairs, and unpaired surrogates; +- serialize, standalone deserialize, and field deserialize; +- v128, logical-v256, and logical-v512 on at least AVX2-only and AVX-512 hosts. + +Use the results to set a minimum length for wide entry and to choose the default +per host/lowering. A width should not be selected from vector width alone. + +## Bottom line + +simdjson's architecture is successful because it narrows wide native work into +a small, stable scalar-mask interface. For `json-as`, the next optimization +should therefore be **fewer, deeper `as-simd` operations over a fixed 64-byte +logical block**, not more general-purpose wide register operations in the hot +loop. AVX2 and AVX-512 should differ in lowering, while the AssemblyScript +algorithm, mask layout, tail handling, and correctness tests remain the same. diff --git a/assembly/__tests__/wago-wide.fixture.ts b/assembly/__tests__/wago-wide.fixture.ts new file mode 100644 index 00000000..dfb90c32 --- /dev/null +++ b/assembly/__tests__/wago-wide.fixture.ts @@ -0,0 +1,121 @@ +import { JSON } from ".."; +import { v256r, v512r } from "as-simd/assembly/wide/wide"; + + +@json +class WideStringFixture { + text: string = ""; +} + +const PLAIN = + "0123456789abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ-_" + + "0123456789abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ-_"; + +const ESCAPED = + "0123456789abcdefghijklmnopqrstuv" + + '\\quoted"\n' + + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const PLAIN_JSON = '"' + PLAIN + '"'; +const PLAIN_2 = PLAIN + PLAIN; +const PLAIN_4 = PLAIN_2 + PLAIN_2; +const PLAIN_8 = PLAIN_4 + PLAIN_4; +const PLAIN_16 = PLAIN_8 + PLAIN_8; +const PLAIN_16_JSON = '"' + PLAIN_16 + '"'; +let reusableSerialize = ""; +let reusableSerializeLong = ""; + +const WIDE_PROBE = memory.data([ + 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 7, +]); + +/** + * End-to-end correctness signal used by scripts/test-wago-wide.mjs. + * Returns zero on success so the host does not need AssemblyScript strings. + */ +export function verify(): i32 { + if (JSON_SIMD_WIDTH == 512) { + v512r.load(0, WIDE_PROBE); + v512r.splat(1, 7); + v512r.eq(2, 0, 1); + if (v512r.bitmask(2) != 0x80000001) return 5; + } else { + v256r.load(0, WIDE_PROBE); + v256r.splat(1, 7); + v256r.eq(2, 0, 1); + if (v256r.bitmask(2) != 1) return 5; + } + + const plainJson = JSON.stringify(PLAIN); + if (JSON.parse(plainJson) != PLAIN) return 1; + + const escapedJson = JSON.stringify(ESCAPED); + if (JSON.parse(escapedJson) != ESCAPED) return 2; + let reused = JSON.stringify(PLAIN, ""); + if (reused != plainJson) return 6; + reused = JSON.stringify(ESCAPED, reused); + if (reused != escapedJson) return 7; + + const objectJson = '{"text":' + escapedJson + "}"; + const value = JSON.parse(objectJson); + if (value.text != ESCAPED) return 3; + if (JSON.parse(JSON.stringify(value)).text != ESCAPED) + return 4; + + return 0; +} + +export function benchSerialize(iterations: i32): i32 { + let checksum = 0; + for (let i = 0; i < iterations; i++) { + checksum += JSON.stringify(PLAIN).length; + } + return checksum; +} + +export function benchDeserialize(iterations: i32): i32 { + let checksum = 0; + for (let i = 0; i < iterations; i++) { + checksum += JSON.parse(PLAIN_JSON).length; + } + return checksum; +} + +export function benchSerializeLong(iterations: i32): i32 { + let checksum = 0; + for (let i = 0; i < iterations; i++) { + checksum += JSON.stringify(PLAIN_16).length; + } + return checksum; +} + +export function benchSerializeReuse(iterations: i32): i32 { + let checksum = 0; + for (let i = 0; i < iterations; i++) { + reusableSerialize = JSON.stringify(PLAIN, reusableSerialize); + checksum += reusableSerialize.length; + } + return checksum; +} + +export function benchSerializeReuseLong(iterations: i32): i32 { + let checksum = 0; + for (let i = 0; i < iterations; i++) { + reusableSerializeLong = JSON.stringify( + PLAIN_16, + reusableSerializeLong, + ); + checksum += reusableSerializeLong.length; + } + return checksum; +} + +export function benchDeserializeLong(iterations: i32): i32 { + let checksum = 0; + for (let i = 0; i < iterations; i++) { + checksum += JSON.parse(PLAIN_16_JSON).length; + } + return checksum; +} diff --git a/assembly/deserialize/simd/string.ts b/assembly/deserialize/simd/string.ts index b9ef9a34..260deba6 100644 --- a/assembly/deserialize/simd/string.ts +++ b/assembly/deserialize/simd/string.ts @@ -8,6 +8,12 @@ import { hex4_to_u16_swar } from "../../util/swar"; import { markProductionParseError } from "../error"; import { isValidStringEscape } from "../string-validation"; import { probeStringFieldTrace, recordStringFieldTrace } from "../parseMode"; +import { + simdWidthBytes, + wideEq16Mask, + wideEqEither16Mask, + wideQuoteBackslashMask64, +} from "../../util/wideSimd"; // @ts-expect-error: @lazy is a valid decorator @lazy const SPLAT_5C = i16x8.splat(0x5c); // \ @@ -210,6 +216,14 @@ export function deserializeString_SIMD(srcStart: usize, srcEnd: usize): string { const payloadStart = srcStart; do { const srcEnd16Fast = srcEnd - 16; + const wideBytes = simdWidthBytes(); + + if (wideBytes > 16) { + while (srcStart + wideBytes < srcEnd) { + if (wideEq16Mask(srcStart, 0x5c) != 0) break; + srcStart += wideBytes; + } + } while (srcStart < srcEnd16Fast) { const block = load(srcStart); @@ -229,6 +243,21 @@ export function deserializeString_SIMD(srcStart: usize, srcEnd: usize): string { srcStart = payloadStart; const srcEnd16 = srcEnd - 16; + const wideBytes = simdWidthBytes(); + + if (wideBytes > 16) { + while (srcStart + wideBytes <= srcEnd) { + const mask = wideEq16Mask(srcStart, 0x5c); + if (mask != 0) { + return deserializeEscapedString_SIMD( + payloadStart, + srcStart + (usize(ctz(mask)) << 1), + srcEnd, + ); + } + srcStart += wideBytes; + } + } while (srcStart < srcEnd16) { const block = load(srcStart); @@ -445,6 +474,7 @@ export function deserializeStringFieldTrusted_SIMD( const srcEnd32 = srcEnd - 32; const srcEnd16 = srcEnd - 16; let srcStart = payloadStart; + const wideBytes = simdWidthBytes(); // Most JSON strings end in their first eight code units. Keep that case on // the smaller single-load path and use packed scanning only for longer runs. @@ -479,6 +509,41 @@ export function deserializeStringFieldTrusted_SIMD( srcStart += 16; } + if (wideBytes > 16) { + while (srcStart + wideBytes <= srcEnd) { + const mask = + JSON_SIMD_WIDTH == 512 + ? wideQuoteBackslashMask64(srcStart) + : wideEqEither16Mask(srcStart, 0x5c, 0x22); + if (mask == 0) { + srcStart += wideBytes; + continue; + } + + const laneIdx = usize(ctz(mask)) << 1; + const srcIdx = srcStart + laneIdx; + const char = load(srcIdx); + if (char == QUOTE) { + writeStringToField_SIMD( + dstFieldPtr, + payloadStart, + (srcIdx - payloadStart), + ); + const next = srcIdx + 2; + recordStringFieldTrace(dstFieldPtr, payloadStart, next); + return next; + } + const next = deserializeEscapedStringField_SIMD( + payloadStart, + srcIdx, + srcEnd, + dstFieldPtr, + ); + if (next != 0) recordStringFieldTrace(dstFieldPtr, payloadStart, next); + return next; + } + } + // Narrow two UTF-16 vectors into one byte vector. Unsigned narrowing // saturates non-ASCII code units to 0xff, so they cannot alias either JSON // delimiter and do not require a separate ASCII-classification pass. diff --git a/assembly/index.d.ts b/assembly/index.d.ts index 2d5eb2f0..3cfcfd54 100644 --- a/assembly/index.d.ts +++ b/assembly/index.d.ts @@ -267,6 +267,23 @@ declare function deserializer( */ declare const JSON_MODE: JSONMode; +/** + * SIMD scan width selected by the json-as transform. The default is 128. + * + * Set `JSON_SIMD_WIDTH=256` or `JSON_SIMD_WIDTH=512` and compile with both + * `json-as` and `as-simd` transforms plus `--enable simd` to emit Wago Wide + * imports. + */ +declare const JSON_SIMD_WIDTH: i32; + +/** + * Whether this build targets Wago with the JairusSW/wide plugin. + * + * Injected from `WAGO_PLUGINS=wide`; used to retain native custom-instruction + * calls only when the matching runtime plugin is present. + */ +declare const JSON_WAGO_WIDE: bool; + /** * Whether strict RFC 8259 validation is enabled. Injected by the transform from * the `JSON_STRICT` build-time environment variable (default `false`). diff --git a/assembly/index.ts b/assembly/index.ts index 694474bd..84c5b2ea 100644 --- a/assembly/index.ts +++ b/assembly/index.ts @@ -82,6 +82,10 @@ import { skipPrettyWhitespace_SIMD, } from "./util/prettyWhitespaceSimd"; import { normalizeJSONEncoding, validateJSON } from "./util/validateJson"; +import { + trySerializeCleanStringV512, + trySerializeCleanStringV512Into, +} from "./serialize/simd/string"; const VAL_QNAN: u64 = 0x7ffc000000000000; // boxed signature (quiet NaN) const VAL_TAG_SHIFT: u8 = 45; @@ -342,6 +346,12 @@ export namespace JSON { } return NULL_WORD; } else if (isString>()) { + if (JSON_WAGO_WIDE && JSON_SIMD_WIDTH == 512) { + const direct = out + ? trySerializeCleanStringV512Into(data as string, out) + : trySerializeCleanStringV512(data as string); + if (direct != 0) return changetype(direct); + } serializeString(data as string); return out ? bs.outTo(changetype(out)) : bs.out(); // @ts-expect-error: Defined by transform diff --git a/assembly/serialize/simd/string.ts b/assembly/serialize/simd/string.ts index 86c6c577..920ca6a2 100644 --- a/assembly/serialize/simd/string.ts +++ b/assembly/serialize/simd/string.ts @@ -3,6 +3,12 @@ import { bs } from "../../../lib/as-bs"; import { BACK_SLASH } from "../../custom/chars"; import { SERIALIZE_ESCAPE_TABLE } from "../../globals/tables"; import { u16_to_hex4_swar } from "../../util/swar"; +import { + copyStringAndEscapeMask64, + copyStringAndEscapeMask256, + copyStringAndEscapeMaskBulkV512, + fusedStringBlockBytes, +} from "../../util/wideSimd"; // @ts-expect-error: @lazy is a valid decorator @lazy const U00_MARKER = 13511005048209500; // @ts-expect-error: @lazy is a valid decorator @@ -16,6 +22,92 @@ import { u16_to_hex4_swar } from "../../util/swar"; // @ts-expect-error: @lazy is a valid decorator @lazy const SPLAT_FFD8 = i16x8.splat(i16(0xd7fe)); +/** + * Serializes an escape-free string directly into its final managed result. + * + * Returns the result pointer on success and zero when the regular escaping + * serializer must be used. This is intentionally a top-level Wago/v512 fast + * path: writing into the final allocation avoids both the shared byte-buffer + * staging copy and `bs.out`'s second copy. + */ +function trySerializeCleanStringV512At( + src: string, + out: usize, + srcSize: usize, +): usize { + let srcStart = changetype(src); + const srcEnd = srcStart + srcSize; + let dst = out + 2; + + store(out, 34); + + const bulkSize = (srcSize >> 6) << 6; + if (bulkSize != 0) { + if ( + copyStringAndEscapeMaskBulkV512( + dst, + srcStart, + dst + bulkSize - 64, + srcStart + bulkSize - 64, + ) != 0 + ) + return 0; + srcStart += bulkSize; + dst += bulkSize; + } + while (srcStart + 16 <= srcEnd) { + const block = load(srcStart); + const mask = i8x16.bitmask( + v128.or( + i16x8.eq(block, SPLAT_0022), + v128.or( + i16x8.eq(block, SPLAT_005C), + v128.or(i16x8.lt_u(block, SPLAT_0020), i8x16.gt_u(block, SPLAT_FFD8)), + ), + ), + ); + if (mask != 0) return 0; + store(dst, block); + srcStart += 16; + dst += 16; + } + while (srcStart < srcEnd) { + const code = load(srcStart); + if ( + code == 34 || + code == BACK_SLASH || + code < 32 || + (code >= 0xd800 && code <= 0xdfff) + ) + return 0; + store(dst, code); + srcStart += 2; + dst += 2; + } + + store(dst, 34); + return out; +} + +/** Direct-result clean-string path with a fresh managed allocation. */ +export function trySerializeCleanStringV512(src: string): usize { + const srcPtr = changetype(src); + const srcSize = changetype(srcPtr - TOTAL_OVERHEAD).rtSize; + const out = __new(srcSize + 4, idof()); + return trySerializeCleanStringV512At(src, out, srcSize); +} + +/** Direct-result clean-string path using JSON.stringify's reusable output. */ +export function trySerializeCleanStringV512Into( + src: string, + out: string, +): usize { + const srcPtr = changetype(src); + const srcSize = changetype(srcPtr - TOTAL_OVERHEAD).rtSize; + const renewed = __renew(changetype(out), srcSize + 4); + return trySerializeCleanStringV512At(src, renewed, srcSize); +} + /** * Serializes strings into their JSON counterparts using SIMD operations */ @@ -32,6 +124,22 @@ export function serializeString_SIMD(src: string): void { const dstStart = bs.offset; let dst = dstStart + 2; + if (JSON_SIMD_WIDTH > 128) { + const wideBytes = fusedStringBlockBytes(); + if (JSON_SIMD_WIDTH == 512) { + while (srcStart + 256 <= srcEnd) { + if (copyStringAndEscapeMask256(dst, srcStart) != 0) break; + srcStart += 256; + dst += 256; + } + } + while (srcStart + wideBytes <= srcEnd) { + if (copyStringAndEscapeMask64(dst, srcStart) != 0) break; + srcStart += wideBytes; + dst += wideBytes; + } + } + while (srcStart < srcEnd16Fast) { const block = load(srcStart); const eq22 = i16x8.eq(block, SPLAT_0022); @@ -72,6 +180,22 @@ export function serializeString_SIMD(src: string): void { store(bs.offset, 34); // " bs.offset += 2; + if (JSON_SIMD_WIDTH > 128) { + const wideBytes = fusedStringBlockBytes(); + if (JSON_SIMD_WIDTH == 512) { + while (srcStart + 256 <= srcEnd) { + if (copyStringAndEscapeMask256(bs.offset, srcStart) != 0) break; + bs.offset += 256; + srcStart += 256; + } + } + while (srcStart + wideBytes <= srcEnd) { + if (copyStringAndEscapeMask64(bs.offset, srcStart) != 0) break; + bs.offset += wideBytes; + srcStart += wideBytes; + } + } + while (srcStart < srcEnd16) { const block = load(srcStart); diff --git a/assembly/util/wideSimd.ts b/assembly/util/wideSimd.ts new file mode 100644 index 00000000..4fea6258 --- /dev/null +++ b/assembly/util/wideSimd.ts @@ -0,0 +1,142 @@ +import { v256r, v512r } from "as-simd/assembly/wide/wide"; +import { + json_escape_copy_utf16_64, + json_escape_copy_utf16_64_v512, + json_escape_copy_utf16_256_v512, + json_escape_copy_utf16_bulk_v512, + json_find_quote_backslash_utf16_64_v512, +} from "as-simd/assembly/wide/json"; + +/** Number of bytes consumed by the configured SIMD scan width. */ +@inline +export function simdWidthBytes(): usize { + if (JSON_SIMD_WIDTH == 512) return 64; + if (JSON_SIMD_WIDTH == 256) return 32; + return 16; +} + +/** Fixed logical block size used by the fused UTF-16 JSON fast path. */ +@inline +export function fusedStringBlockBytes(): usize { + return 64; +} + +/** + * Copies one 64-byte UTF-16 block and returns one escape bit per code unit. + * + * Wago/Wide lowers the import directly to native wide SIMD. Other runtimes + * retain as-simd's portable four-v128 implementation. + */ +@inline +export function copyStringAndEscapeMask64(dst: usize, src: usize): u32 { + if (JSON_WAGO_WIDE) { + if (JSON_SIMD_WIDTH == 512) return json_escape_copy_utf16_64_v512(src, dst); + return json_escape_copy_utf16_64(src, dst); + } + return v512r.copy_json_escape_bitmask_utf16_64(src, dst); +} + +/** Copy/classify four ZMM blocks with one bounds check and constant setup. */ +@inline +export function copyStringAndEscapeMask256(dst: usize, src: usize): u32 { + if (JSON_WAGO_WIDE && JSON_SIMD_WIDTH == 512) + return json_escape_copy_utf16_256_v512(src, dst); + return ( + v512r.copy_json_escape_bitmask_utf16_64(src, dst) | + v512r.copy_json_escape_bitmask_utf16_64(src + 64, dst + 64) | + v512r.copy_json_escape_bitmask_utf16_64(src + 128, dst + 128) | + v512r.copy_json_escape_bitmask_utf16_64(src + 192, dst + 192) + ); +} + +/** Copy/classify an inclusive run of complete ZMM blocks in one native loop. */ +@inline +export function copyStringAndEscapeMaskBulkV512( + dst: usize, + src: usize, + lastDst: usize, + lastSrc: usize, +): u32 { + return json_escape_copy_utf16_bulk_v512(src, dst, lastSrc, lastDst); +} + +/** One mask bit per UTF-16 lane equal to `code`. */ +@inline +export function wideEq16Mask(ptr: usize, code: i16): u64 { + if (JSON_SIMD_WIDTH == 512) { + return v512r.eq_splat_bitmask(ptr, code); + } + if (JSON_SIMD_WIDTH == 256) { + return v256r.eq_splat_bitmask(ptr, code); + } + return i16x8.bitmask(i16x8.eq(load(ptr), i16x8.splat(code))); +} + +/** One mask bit per UTF-16 lane equal to either `a` or `b`. */ +@inline +export function wideEqEither16Mask(ptr: usize, a: i16, b: i16): u64 { + if (JSON_SIMD_WIDTH == 512) { + return v512r.eq_either_splat_bitmask(ptr, a, b); + } + if (JSON_SIMD_WIDTH == 256) { + return v256r.eq_either_splat_bitmask(ptr, a, b); + } + const block = load(ptr); + return ( + i16x8.bitmask( + v128.or(i16x8.eq(block, i16x8.splat(a)), i16x8.eq(block, i16x8.splat(b))), + ) + ); +} + +/** Quote-or-backslash lane mask using the fused AVX-512 JSON primitive. */ +@inline +export function wideQuoteBackslashMask64(ptr: usize): u32 { + if (JSON_WAGO_WIDE && JSON_SIMD_WIDTH == 512) + return json_find_quote_backslash_utf16_64_v512(ptr); + return v512r.eq_either_splat_bitmask(ptr, 0x22, 0x5c); +} + +/** + * Finds JSON string characters requiring the scalar escape path. + * + * One bit is returned per byte, matching the native i8x16 mask used by the + * existing 128-bit serializer. For UTF-16 ASCII lanes only the even bit can + * be set; non-ASCII or surrogate bytes can also set the high-byte bit. + */ +@inline +export function wideStringEscapeMask(ptr: usize): u64 { + if (JSON_SIMD_WIDTH == 512) { + return v512r.json_escape_bitmask_utf16(ptr); + } + if (JSON_SIMD_WIDTH == 256) { + return v256r.json_escape_bitmask_utf16(ptr); + } + const block = load(ptr); + return ( + i8x16.bitmask( + v128.or( + i16x8.eq(block, i16x8.splat(0x22)), + v128.or( + i16x8.eq(block, i16x8.splat(0x5c)), + v128.or( + i16x8.lt_u(block, i16x8.splat(0x20)), + i8x16.gt_u(block, i16x8.splat(i16(0xd7fe))), + ), + ), + ), + ) + ); +} + +/** Copy one configured-width vector from `src` to `dst`. */ +@inline +export function copyWide(dst: usize, src: usize): void { + if (JSON_SIMD_WIDTH == 512) { + v512r.copy(dst, src); + } else if (JSON_SIMD_WIDTH == 256) { + v256r.copy(dst, src); + } else { + store(dst, load(src)); + } +} diff --git a/package.json b/package.json index cb35d38d..bda5724f 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "test": "ast test --parallel --enable try-as", "test:rfc": "ast test --config rfc.config.json --parallel --enable try-as", "test:transform": "node transform/__tests__/normalize-base-rel.test.mjs && node transform/__tests__/compute-base-rel.test.mjs && node transform/__tests__/resolve-imports.test.mjs", + "test:wago-wide": "npm run build:transform && node scripts/test-wago-wide.mjs", "test:fast": "npm run build:transform && JSON_USE_FAST_PATH=1 ast test --parallel --mode swar,simd --enable try-as", "fuzz": "ast fuzz", "test:fuzz": "ast test --fuzz --parallel", @@ -147,6 +148,7 @@ "type": "module", "types": "assembly/index.ts", "dependencies": { + "as-simd": "git+https://github.com/JairusSW/as-simd.git#486d5c0", "xjb-as": "^0.1.0" } } diff --git a/scripts/bench-wago-classic.mjs b/scripts/bench-wago-classic.mjs new file mode 100644 index 00000000..77e7866a --- /dev/null +++ b/scripts/bench-wago-classic.mjs @@ -0,0 +1,331 @@ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceFlavor = process.env.WAGO_CLASSIC_SOURCE_FLAVOR ?? "eager"; +if (!["eager", "lazy", "obj"].includes(sourceFlavor)) { + throw new Error( + `WAGO_CLASSIC_SOURCE_FLAVOR must be eager, lazy, or obj; got ${sourceFlavor}`, + ); +} +const sourceTag = sourceFlavor === "eager" ? "" : `.${sourceFlavor}`; +const output = path.join( + root, + "build", + sourceFlavor === "eager" ? "wago-classic" : `wago-classic-${sourceFlavor}`, +); +const wideDir = process.env.WIDE_DIR ?? path.resolve(root, "..", "wide"); +const datasets = [ + "twitter", + "canada", + "citm_catalog", + "poet", + "github_events", + "gsoc-2018", + "lottie", + "otfcc", + "fgo", +]; +const variants = [ + { label: "swar", mode: "SWAR", width: 128, plugin: false }, + { label: "v128", mode: "SIMD", width: 128, plugin: false }, + { label: "v256", mode: "SIMD", width: 256, plugin: true }, + { label: "v512", mode: "SIMD", width: 512, plugin: true }, +]; +const datasetFilter = new Set( + (process.env.WAGO_CLASSIC_FILTER ?? "").split(",").filter(Boolean), +); + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: options.cwd ?? root, + env: options.env ?? process.env, + encoding: options.encoding, + stdio: options.stdio ?? "inherit", + }); +} + +mkdirSync(output, { recursive: true }); +if (process.env.WAGO_CLASSIC_RUN_ONLY !== "1") + for (const dataset of datasets) { + if (datasetFilter.size !== 0 && !datasetFilter.has(dataset)) continue; + const canonicalSource = path.join( + root, + "assembly", + "__benches__", + "classic", + `${dataset}${sourceTag}.bench.ts`, + ); + let source = canonicalSource; + // The classic chart compares fresh minified deserialize and serialize. + // Reuse has its own series and, on GitHub Events, currently exposes an + // unrelated SIMD parse-into bounds trap, so omit those cases by default. + if (process.env.WAGO_CLASSIC_INCLUDE_REUSE !== "1") { + source = path.join( + root, + "assembly", + "__benches__", + "classic", + `${dataset}${sourceTag}.wago.bench.ts`, + ); + const withoutReuse = readFileSync(canonicalSource, "utf8").replace( + /\nbench\(\n {2}"Deserialize[^"]*\(min, reuse\)",[\s\S]*?\n\);\ndumpToFile\([^\n]*-reuse[^\n]*\);\n/g, + "\n", + ); + writeFileSync(source, withoutReuse); + } + for (const variant of variants) { + const wasm = path.join(output, `${dataset}.${variant.label}.wasm`); + const args = [ + source, + "--transform", + "./transform", + "-O3", + "--noAssert", + "--uncheckedBehavior", + "always", + "--runtime", + "incremental", + "--enable", + "bulk-memory", + "--exportStart", + "start", + "--exportRuntime", + "-o", + wasm, + ]; + if (variant.mode === "SIMD") args.push("--enable", "simd"); + if (variant.plugin) args.splice(3, 0, "--transform", "as-simd"); + run(path.join(root, "node_modules", ".bin", "asc"), args, { + env: { + ...process.env, + JSON_CACHE: "0", + JSON_MODE: variant.mode, + JSON_SIMD_WIDTH: String(variant.width), + WAGO_PLUGINS: variant.plugin ? "wide" : "", + }, + }); + process.stderr.write(`built ${dataset} ${variant.label}\n`); + } + if (source !== canonicalSource) rmSync(source); + } + +const temporaryModule = mkdtempSync( + path.join(os.tmpdir(), "json-as-wago-classic-"), +); +writeFileSync( + path.join(temporaryModule, "go.mod"), + "module json-as-wago-classic\n\ngo 1.22\n", +); +writeFileSync( + path.join(temporaryModule, "main.go"), + `package main + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" + "unicode/utf16" + + wide "github.com/JairusSW/wide" + wago "github.com/wago-org/wago" +) + +type benchResult struct { + GBPS float64 \`json:"gbps"\` +} + +func liftString(memory []byte, ptr uint32) string { + if ptr == 0 || ptr < 4 || int(ptr) > len(memory) { + return "" + } + size := binary.LittleEndian.Uint32(memory[ptr-4:]) + end := uint64(ptr) + uint64(size) + if end > uint64(len(memory)) { + panic("guest string is out of bounds") + } + runes := make([]uint16, size/2) + for i := range runes { + runes[i] = binary.LittleEndian.Uint16(memory[int(ptr)+i*2:]) + } + return string(utf16.Decode(runes)) +} + +func main() { + if len(os.Args) != 3 { + panic("usage: runner ") + } + wasmDir, root := os.Args[1], os.Args[2] + entries, err := filepath.Glob(filepath.Join(wasmDir, "*.wasm")) + if err != nil { + panic(err) + } + sort.Strings(entries) + filter := map[string]bool{} + for _, name := range strings.Split(os.Getenv("WAGO_CLASSIC_FILTER"), ",") { + if name != "" { + filter[name] = true + } + } + fmt.Println("dataset\\tmode\\tdeserialize_gbps\\tdeserialize_reuse_gbps\\tserialize_gbps") + for _, wasmPath := range entries { + base := strings.TrimSuffix(filepath.Base(wasmPath), ".wasm") + split := strings.LastIndexByte(base, '.') + if split < 0 { + panic("invalid module name " + base) + } + dataset, mode := base[:split], base[split+1:] + if len(filter) != 0 && !filter[dataset] { + continue + } + code, err := os.ReadFile(wasmPath) + if err != nil { + panic(err) + } + rt := wago.NewRuntime() + if err := rt.Use(wide.New()); err != nil { + panic(err) + } + module, err := rt.Compile(code) + if err != nil { + panic(fmt.Errorf("compile %s: %w", base, err)) + } + + payloadPointers := map[string]uint32{} + captured := map[string]string{} + started := time.Now() + imports := wago.Imports{ + "env.abort": wago.HostFunc(func(m wago.HostModule, p, _ []uint64) { + panic(fmt.Sprintf("abort: %s:%d", liftString(m.Memory(), uint32(p[1])), uint32(p[2]))) + }), + "env.console.log": wago.HostFunc(func(m wago.HostModule, p, _ []uint64) { + if os.Getenv("WAGO_CLASSIC_VERBOSE") == "1" { + fmt.Fprintln(os.Stderr, base+": "+liftString(m.Memory(), uint32(p[0]))) + } + }), + "env.Date.now": wago.HostFunc(func(_ wago.HostModule, _, r []uint64) { + r[0] = math.Float64bits(float64(time.Now().UnixMilli())) + }), + "env.performance.now": wago.HostFunc(func(_ wago.HostModule, _, r []uint64) { + r[0] = math.Float64bits(float64(time.Since(started).Nanoseconds()) / 1e6) + }), + "env.readFile": wago.HostFunc(func(m wago.HostModule, p, r []uint64) { + name := liftString(m.Memory(), uint32(p[0])) + ptr, ok := payloadPointers[name] + if !ok { + panic("unprepared payload " + name) + } + r[0] = wago.I32(int32(ptr)) + }), + "env.writeFile": wago.HostFunc(func(m wago.HostModule, p, _ []uint64) { + captured[liftString(m.Memory(), uint32(p[0]))] = + liftString(m.Memory(), uint32(p[1])) + }), + } + instance, err := rt.Instantiate( + context.Background(), + module, + wago.WithImports(imports), + ) + if err != nil { + panic(fmt.Errorf("instantiate %s: %w", base, err)) + } + + for _, flavor := range []string{"pretty", "min"} { + rel := "./assembly/__benches__/payloads/" + dataset + "." + flavor + ".json" + data, err := os.ReadFile(filepath.Join(root, rel)) + if os.IsNotExist(err) { + continue + } + if err != nil { + panic(err) + } + out, err := instance.Invoke("__new", wago.I32(int32(len(data))), wago.I32(1)) + if err != nil { + panic(fmt.Errorf("allocate %s: %w", rel, err)) + } + ptr := uint32(wago.AsI32(out[0])) + copy(instance.Memory().Bytes()[int(ptr):int(ptr)+len(data)], data) + payloadPointers[rel] = ptr + } + if _, err := instance.Invoke("start"); err != nil { + fmt.Printf("%s\\t%s\\tTRAP\\tTRAP\\t%s\\n", dataset, mode, err) + instance.Close() + rt.Close() + continue + } + readRate := func(kind string) float64 { + flavor := os.Getenv("WAGO_CLASSIC_SOURCE_FLAVOR") + if flavor == "" || flavor == "eager" { + flavor = "" + } else { + flavor = "-" + flavor + } + suffix := dataset + flavor + "-min." + kind + ".as.json" + for name, raw := range captured { + if strings.HasSuffix(name, suffix) { + var result benchResult + if err := json.Unmarshal([]byte(raw), &result); err != nil { + panic(err) + } + return result.GBPS + } + } + panic("missing result " + suffix) + } + readOptionalRate := func(suffix string) string { + for name, raw := range captured { + if strings.HasSuffix(name, suffix) { + var result benchResult + if err := json.Unmarshal([]byte(raw), &result); err != nil { + panic(err) + } + return fmt.Sprintf("%.6f", result.GBPS) + } + } + return "-" + } + reuseSuffix := dataset + "-min-reuse.deserialize.as.json" + fmt.Printf("%s\\t%s\\t%.6f\\t%s\\t%.6f\\n", dataset, mode, readRate("deserialize"), readOptionalRate(reuseSuffix), readRate("serialize")) + instance.Close() + rt.Close() + } +} +`, +); + +try { + run( + "go", + [ + "mod", + "edit", + "-require=github.com/JairusSW/wide@v0.0.0", + `-replace=github.com/JairusSW/wide=${wideDir}`, + ], + { cwd: temporaryModule }, + ); + run("go", ["get", "github.com/wago-org/wago@latest"], { + cwd: temporaryModule, + }); + run("go", ["mod", "tidy"], { cwd: temporaryModule }); + run("go", ["run", ".", output, root], { cwd: temporaryModule }); +} finally { + rmSync(temporaryModule, { recursive: true, force: true }); +} diff --git a/scripts/test-wago-wide.mjs b/scripts/test-wago-wide.mjs new file mode 100644 index 00000000..6d923e57 --- /dev/null +++ b/scripts/test-wago-wide.mjs @@ -0,0 +1,288 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const output = path.join(root, "build", "wago-wide"); +const fixture = "assembly/__tests__/wago-wide.fixture.ts"; +const goEnv = { + ...process.env, + GONOSUMDB: [process.env.GONOSUMDB, "github.com/JairusSW/wide"] + .filter(Boolean) + .join(","), +}; + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: options.cwd ?? root, + env: options.env ?? (command === "go" ? goEnv : process.env), + encoding: options.encoding, + stdio: options.stdio ?? "inherit", + }); +} + +mkdirSync(output, { recursive: true }); +for (const width of [128, 256, 512]) { + const wasm = path.join(output, `json-v${width}.wasm`); + run( + path.join(root, "node_modules", ".bin", "asc"), + [ + fixture, + "--transform", + "./transform", + "--transform", + "as-simd", + "--runtime", + "stub", + "-O3", + "--enable", + "simd", + "-o", + wasm, + ], + { + env: { + ...process.env, + JSON_MODE: "SIMD", + JSON_SIMD_WIDTH: String(width), + WAGO_PLUGINS: "wide", + }, + }, + ); + + const imports = WebAssembly.Module.imports( + new WebAssembly.Module(readFileSync(wasm)), + ); + if (width > 128) { + assert( + imports.some( + ({ module, name }) => + module === "as-simd" && + (name === `v${width}.load` || + (width === 512 && name === "json.escape_copy_utf16_bulk.v512")), + ), + `v${width} build did not emit a native Wide operation`, + ); + assert( + imports.some( + ({ module, name }) => + module === "as-simd" && + (name === `i16x${width / 16}.eq` || + (width === 512 && name === "i16x16.eq")), + ), + `v${width} build did not emit a native-width comparison`, + ); + assert( + imports.some( + ({ module, name }) => + module === "as-simd" && + name === + (width === 512 + ? "json.escape_copy_utf16_64.v512" + : "json.escape_copy_utf16_64"), + ), + `v${width} build did not emit the fused UTF-16 JSON copy/classifier`, + ); + if (width === 512) { + assert( + imports.some( + ({ module, name }) => + module === "as-simd" && name === "json.escape_copy_utf16_256.v512", + ), + "v512 build did not emit the four-ZMM UTF-16 JSON copy/classifier", + ); + assert( + imports.some( + ({ module, name }) => + module === "as-simd" && name === "json.escape_copy_utf16_bulk.v512", + ), + "v512 build did not emit the bulk UTF-16 JSON copy/classifier", + ); + } + } +} + +const temporaryModule = mkdtempSync( + path.join(os.tmpdir(), "json-as-wago-wide-"), +); +writeFileSync( + path.join(temporaryModule, "go.mod"), + "module json-as-wago-wide-integration\n\ngo 1.22\n", +); +writeFileSync( + path.join(temporaryModule, "main.go"), + `package main + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + wide "github.com/JairusSW/wide" + wago "github.com/wago-org/wago" +) + +func main() { + serializeRates := map[string]float64{} + for _, wasmPath := range os.Args[1:] { + wasm, err := os.ReadFile(wasmPath) + if err != nil { + panic(err) + } + runtime := wago.NewRuntime() + if err := runtime.Use(wide.New()); err != nil { + panic(fmt.Errorf("register Wide: %w", err)) + } + module, err := runtime.Compile(wasm) + if err != nil { + panic(fmt.Errorf("compile %s: %w", wasmPath, err)) + } + if !module.Compiled().RequiresAVX2() { + panic(fmt.Errorf("%s did not select native wide lowering", wasmPath)) + } + if strings.Contains(wasmPath, "v512") && !module.Compiled().RequiresAVX512() { + panic(fmt.Errorf("%s did not select AVX-512 JSON lowering", wasmPath)) + } + abort := wago.HostFunc(func(_ wago.HostModule, _, _ []uint64) { + panic("AssemblyScript abort") + }) + instance, err := runtime.Instantiate( + context.Background(), + module, + wago.WithImports(wago.Imports{"env.abort": abort}), + ) + if err != nil { + panic(fmt.Errorf("instantiate %s: %w", wasmPath, err)) + } + result, err := instance.Invoke("verify") + if err != nil { + panic(fmt.Errorf("verify %s: %w", wasmPath, err)) + } + if len(result) != 1 || wago.AsI32(result[0]) != 0 { + panic(fmt.Errorf("verify %s returned %v", wasmPath, result)) + } + if os.Getenv("WAGO_BENCH") == "1" { + for _, bench := range []struct { + name string + bytes float64 + iterations int32 + }{ + {"benchSerialize", 256, 200000}, + {"benchDeserialize", 260, 200000}, + {"benchSerializeLong", 4096, 20000}, + {"benchSerializeReuse", 256, 200000}, + {"benchSerializeReuseLong", 4096, 20000}, + {"benchDeserializeLong", 4100, 20000}, + } { + if _, err := instance.Invoke(bench.name, wago.I32(1000)); err != nil { + panic(err) + } + best := time.Duration(1<<63 - 1) + for round := 0; round < 5; round++ { + start := time.Now() + if _, err := instance.Invoke(bench.name, wago.I32(bench.iterations)); err != nil { + panic(err) + } + if elapsed := time.Since(start); elapsed < best { + best = elapsed + } + } + gbps := bench.bytes * float64(bench.iterations) / best.Seconds() / 1e9 + fmt.Printf("bench: %s %s %.3f GB/s\\n", wasmPath, bench.name, gbps) + if bench.name == "benchSerialize" { + switch { + case strings.Contains(wasmPath, "v128"): + serializeRates["v128"] = gbps + case strings.Contains(wasmPath, "v512"): + serializeRates["v512"] = gbps + } + } + if bench.name == "benchSerializeLong" { + switch { + case strings.Contains(wasmPath, "v128"): + serializeRates["v128-long"] = gbps + case strings.Contains(wasmPath, "v512"): + serializeRates["v512-long"] = gbps + } + } + if bench.name == "benchSerializeReuseLong" { + switch { + case strings.Contains(wasmPath, "v128"): + serializeRates["v128-reuse-long"] = gbps + case strings.Contains(wasmPath, "v512"): + serializeRates["v512-reuse-long"] = gbps + } + } + } + } + instance.Close() + runtime.Close() + fmt.Printf("ok: %s\\n", wasmPath) + } + if os.Getenv("WAGO_BENCH_REQUIRE_2X") == "1" { + ratio := serializeRates["v512-reuse-long"] / serializeRates["v128-reuse-long"] + if ratio < 2 { + panic(fmt.Errorf("v512 reusable-output serialize ratio %.3fx is below required 2.000x", ratio)) + } + fmt.Printf("target: v512 reusable-output serialize %.3fx v128\\n", ratio) + } +} +`, +); + +function localModule(module, directory) { + run( + "go", + [ + "mod", + "edit", + `-require=${module}@v0.0.0`, + `-replace=${module}=${path.resolve(root, directory)}`, + ], + { cwd: temporaryModule }, + ); +} + +try { + if (process.env.WIDE_DIR) { + localModule("github.com/JairusSW/wide", process.env.WIDE_DIR); + } else { + run( + "go", + ["get", `github.com/JairusSW/wide@${process.env.WIDE_VERSION ?? "main"}`], + { cwd: temporaryModule }, + ); + } + if (process.env.WAGO_DIR) { + localModule("github.com/wago-org/wago", process.env.WAGO_DIR); + } else if (process.env.WAGO_VERSION) { + run("go", ["get", `github.com/wago-org/wago@${process.env.WAGO_VERSION}`], { + cwd: temporaryModule, + }); + } + run("go", ["mod", "tidy"], { cwd: temporaryModule }); + run( + "go", + [ + "run", + ".", + path.join(output, "json-v128.wasm"), + path.join(output, "json-v256.wasm"), + path.join(output, "json-v512.wasm"), + ], + { cwd: temporaryModule }, + ); +} finally { + rmSync(temporaryModule, { recursive: true, force: true }); +} diff --git a/transform/lib/index.js b/transform/lib/index.js index f2b62f66..02837a83 100644 --- a/transform/lib/index.js +++ b/transform/lib/index.js @@ -3374,7 +3374,12 @@ var JSONMode; })(JSONMode || (JSONMode = {})); let MODE = JSONMode.SWAR; let MODE_TEXT = "SWAR"; +let SIMD_WIDTH = 128; const STAGES = process.env["JSON_STAGES"] !== undefined; +const WAGO_WIDE = (process.env["WAGO_PLUGINS"] ?? "") + .toLowerCase() + .split(/[\s,;]+/) + .includes("wide"); export default class Transformer extends Transform { afterInitialize(program) { if (program.options.hasFeature(16)) @@ -3406,11 +3411,24 @@ export default class Transformer extends Transform { MODE_TEXT = "NAIVE"; break; } + SIMD_WIDTH = 128; + const configuredWidth = process.env["JSON_SIMD_WIDTH"]; + if (configuredWidth !== undefined) { + SIMD_WIDTH = Number.parseInt(configuredWidth.trim(), 10); + if (SIMD_WIDTH !== 128 && SIMD_WIDTH !== 256 && SIMD_WIDTH !== 512) { + throw new Error("JSON_SIMD_WIDTH must be one of 128, 256, or 512"); + } + if (SIMD_WIDTH !== 128 && !program.options.hasFeature(16)) { + throw new Error("JSON_SIMD_WIDTH=256/512 requires AssemblyScript SIMD"); + } + } if (STAGES) console.log("[transform]: Finished initializing transformer in " + MODE_TEXT + " mode"); program.registerConstantInteger("JSON_MODE", Type.i32, i64_new(MODE)); + program.registerConstantInteger("JSON_SIMD_WIDTH", Type.i32, i64_new(SIMD_WIDTH)); + program.registerConstantInteger("JSON_WAGO_WIDE", Type.bool, WAGO_WIDE ? i64_one : i64_zero); program.registerConstantInteger("JSON_STRICT", Type.bool, STRICT ? i64_one : i64_zero); if (JSON_CACHE_CONFIG.enabled) { program.registerConstantInteger("JSON_CACHE", Type.bool, i64_one); diff --git a/transform/src/index.ts b/transform/src/index.ts index fee88008..d29185c0 100644 --- a/transform/src/index.ts +++ b/transform/src/index.ts @@ -4924,7 +4924,12 @@ enum JSONMode { let MODE: JSONMode = JSONMode.SWAR; let MODE_TEXT = "SWAR"; +let SIMD_WIDTH = 128; const STAGES = process.env["JSON_STAGES"] !== undefined; +const WAGO_WIDE = (process.env["WAGO_PLUGINS"] ?? "") + .toLowerCase() + .split(/[\s,;]+/) + .includes("wide"); export default class Transformer extends Transform { afterInitialize(program: Program): void | Promise { @@ -4956,6 +4961,17 @@ export default class Transformer extends Transform { MODE_TEXT = "NAIVE"; break; } + SIMD_WIDTH = 128; + const configuredWidth = process.env["JSON_SIMD_WIDTH"]; + if (configuredWidth !== undefined) { + SIMD_WIDTH = Number.parseInt(configuredWidth.trim(), 10); + if (SIMD_WIDTH !== 128 && SIMD_WIDTH !== 256 && SIMD_WIDTH !== 512) { + throw new Error("JSON_SIMD_WIDTH must be one of 128, 256, or 512"); + } + if (SIMD_WIDTH !== 128 && !program.options.hasFeature(Feature.Simd)) { + throw new Error("JSON_SIMD_WIDTH=256/512 requires AssemblyScript SIMD"); + } + } if (STAGES) console.log( "[transform]: Finished initializing transformer in " + @@ -4964,6 +4980,16 @@ export default class Transformer extends Transform { ); program.registerConstantInteger("JSON_MODE", Type.i32, i64_new(MODE)); + program.registerConstantInteger( + "JSON_SIMD_WIDTH", + Type.i32, + i64_new(SIMD_WIDTH), + ); + program.registerConstantInteger( + "JSON_WAGO_WIDE", + Type.bool, + WAGO_WIDE ? i64_one : i64_zero, + ); program.registerConstantInteger( "JSON_STRICT", Type.bool,