parseman 0.61.5 → 0.61.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,15 @@ export declare function selectHotSites(code: readonly number[], entries: readonl
8
8
  /** Measured calls per site (`countSiteVisits` over a corpus). Replaces the
9
9
  * static estimate when given. */
10
10
  profile?: ArrayLike<number>): Set<number>;
11
+ /**
12
+ * The sites the function for `ip` calls, as `emit-assembly.ts` lowers it. An `OP_CONT`
13
+ * with a body is a late frame, lowered with its wrapper in one function, calling its
14
+ * corner and its tail. With no body, the emitter can't decide there and it's an alias
15
+ * to its wrapper, as `OP_SEED` is to its corner. A gap gate has no body either, so its
16
+ * item counts as the repeat's own child, and adding the row moves no other site's
17
+ * estimate (`gap-gate.ts`).
18
+ */
19
+ export declare function edgesAsLowered(code: readonly number[], siteBytes: ReadonlyMap<number, number>, ip: number, out: number[]): void;
11
20
  /**
12
21
  * The largest prefix of the ranking whose real source fits `ceiling`. `siteBytes`
13
22
  * leaves out what sites add around their bodies, so the estimate can overshoot:
@@ -1 +1 @@
1
- {"version":3,"file":"select-hot.d.ts","sourceRoot":"","sources":["../../src/table/select-hot.ts"],"names":[],"mappings":"AAwBA;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACtC,MAAM,EAAE,MAAM;AACd;iCACiC;AACjC,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B,GAAG,CAAC,MAAM,CAAC,CAgBb;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC;AACtC,2DAA2D;AAC3D,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,KAAK,MAAM,EACnD,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B;IAAE,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQ7E"}
1
+ {"version":3,"file":"select-hot.d.ts","sourceRoot":"","sources":["../../src/table/select-hot.ts"],"names":[],"mappings":"AAqCA;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACtC,MAAM,EAAE,MAAM;AACd;iCACiC;AACjC,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B,GAAG,CAAC,MAAM,CAAC,CAUb;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,CAe/H;AAqDD;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC;AACtC,2DAA2D;AAC3D,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,KAAK,MAAM,EACnD,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,GAC1B;IAAE,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAQ7E"}
package/dist/version.d.ts CHANGED
@@ -13,5 +13,5 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export declare const PARSEMAN_VERSION = "0.61.5";
16
+ export declare const PARSEMAN_VERSION = "0.61.6";
17
17
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.61.5",
3
+ "version": "0.61.6",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "engines": {
6
6
  "node": "^20.19.0 || >=22.13.0"
@@ -65,6 +65,8 @@ type Call = {
65
65
  type Edit = { readonly start: number; readonly end: number; readonly text: string }
66
66
 
67
67
  const PIECE = /^_pf(\d+)$/
68
+ const MERGEABLE = /^_(?:pf|sk)\d+$/
69
+ const TEMP = /^_t\d+_/
68
70
  const WRAP = 'function __parseman_supercompile__(){\n'
69
71
  const SUFFIX = '\n}'
70
72
 
@@ -225,6 +227,113 @@ export function supercompileEntryAssembly(
225
227
  return supercompileEmittedAssembly(prog, cfg, extraIps, emitted)
226
228
  }
227
229
 
230
+ /** A name in the source: a site, or a temporary (`tmp` in `emit-assembly.ts`). */
231
+ type NameUse = { readonly start: number; readonly end: number; readonly name: string }
232
+
233
+ /** The uses of site and temporary names under `root`, and the names bound there. */
234
+ function namesUnder(root: Ast, uses: NameUse[], bound: Set<string>): boolean {
235
+ let plain = true
236
+ const bind = (id: Ast | undefined): void => {
237
+ const name = nameOf(id)
238
+ if (name !== undefined) bound.add(name)
239
+ }
240
+ const walk = (node: Ast, parent: Ast | undefined): void => {
241
+ if (node.type === 'VariableDeclarator') bind(ast(node.id))
242
+ else if (node.type === 'LabeledStatement') bind(ast(node.label))
243
+ else if (isFunction(node)) {
244
+ if (node !== root) bind(ast(node.id))
245
+ nodes(node.params).forEach(bind)
246
+ }
247
+ const name = nameOf(node)
248
+ if (name !== undefined && (MERGEABLE.test(name) || TEMP.test(name))) {
249
+ const isKey = parent?.type === 'Property' && parent.key === node && parent.computed !== true
250
+ const isProperty = parent?.type === 'MemberExpression' && parent.property === node && parent.computed !== true
251
+ if (parent?.type === 'Property' && parent.shorthand === true) plain = false
252
+ if (!isKey && !isProperty) uses.push({ ...range(node), name })
253
+ }
254
+ for (const child of children(node)) walk(child, node)
255
+ }
256
+ walk(root, undefined)
257
+ return plain
258
+ }
259
+
260
+ /**
261
+ * Functions that read the same are one function: `optional(x)` written in 25 places
262
+ * is 25 rows and one body, and a trivia skip can be pooled under two labels that
263
+ * lower alike. This runs after the join, so a site with one caller has
264
+ * already gone into it, the selection is untouched and every call a parse makes is
265
+ * still made. Each pass can make the callers of a merged function read alike, so it
266
+ * repeats until nothing merges.
267
+ *
268
+ * Only identifiers are compared or renamed: a grammar's own text reaches this source
269
+ * as strings and regexes, and they count as written. A temporary is local by the
270
+ * emitter's contract and is numbered per assembly, so one this function binds is
271
+ * compared in order of use. A site name that's also bound somewhere, or abbreviated
272
+ * in `{_pf1}`, can't be renamed by name, so that source stays as it is.
273
+ */
274
+ export function mergeSameBodies(source: string): string {
275
+ const parsed = parseSync('parseman-merge-same-bodies.js', WRAP + source + SUFFIX)
276
+ if (parsed.errors.length > 0) return source
277
+ const statements = nodes(ast(nodes((parsed.program as unknown as Ast).body)[0]?.body)?.body)
278
+ type Body = NameUse & { readonly uses: readonly NameUse[]; readonly local: ReadonlySet<string> }
279
+ const bodies: Body[] = []
280
+ const others: NameUse[] = []
281
+ const declared = new Set<string>()
282
+ for (const statement of statements) {
283
+ const name = statement.type === 'FunctionDeclaration' ? nameOf(ast(statement.id)) : undefined
284
+ const uses: NameUse[] = []
285
+ const bound = new Set<string>()
286
+ if (!namesUnder(statement, uses, bound)) return source
287
+ for (const local of bound) declared.add(local)
288
+ if (name === undefined || !MERGEABLE.test(name)) others.push(...uses)
289
+ else bodies.push({ ...range(statement), name, uses: uses.sort((a, b) => a.start - b.start), local: bound })
290
+ }
291
+ for (const local of declared) if (MERGEABLE.test(local)) return source
292
+
293
+ const merged = new Map<string, string>()
294
+ const current = (name: string): string => {
295
+ let at = name
296
+ for (let next = merged.get(at); next !== undefined; next = merged.get(at)) at = next
297
+ return at
298
+ }
299
+ let live = bodies
300
+ for (let more = true; more;) {
301
+ more = false
302
+ const first = new Map<string, string>()
303
+ live = live.filter(body => {
304
+ const temps = new Map<string, number>()
305
+ const edits = body.uses.map((use): Edit => {
306
+ if (MERGEABLE.test(use.name)) {
307
+ const site = current(use.name)
308
+ return { ...use, text: site === body.name ? '#self' : site }
309
+ }
310
+ if (!body.local.has(use.name)) return { ...use, text: use.name }
311
+ const seen = temps.get(use.name) ?? temps.size
312
+ temps.set(use.name, seen)
313
+ return { ...use, text: `#t${seen}` }
314
+ })
315
+ const key = render(source, body.start, body.end, edits)
316
+ const kept = first.get(key)
317
+ if (kept === undefined) { first.set(key, body.name); return true }
318
+ merged.set(body.name, kept)
319
+ more = true
320
+ return false
321
+ })
322
+ }
323
+ if (merged.size === 0) return source
324
+
325
+ const edits: Edit[] = []
326
+ const rename = (use: NameUse): void => {
327
+ if (MERGEABLE.test(use.name) && merged.has(use.name)) edits.push({ ...use, text: current(use.name) })
328
+ }
329
+ others.forEach(rename)
330
+ for (const body of bodies) {
331
+ if (merged.has(body.name)) edits.push({ start: body.start, end: body.end, text: '' })
332
+ else body.uses.forEach(rename)
333
+ }
334
+ return render(source, 0, source.length, edits)
335
+ }
336
+
228
337
  /**
229
338
  * Plugin-only final splice for an ordinary macro replacement. The table
230
339
  * compiler emits its canonical callback-free public artifact first; this pass
@@ -274,7 +383,7 @@ export function supercompileRuleMapReplacement(
274
383
  const start = body.start - prefix.length + 1
275
384
  const end = body.end - prefix.length - 1
276
385
  if (replacement.slice(start, end) !== FACTORY_ARITY_GUARD + canonical.source + '\n') return replacement
277
- const transformed = replacement.slice(0, start) + FACTORY_ARITY_GUARD + candidate.source + '\n' + replacement.slice(end)
386
+ const transformed = replacement.slice(0, start) + FACTORY_ARITY_GUARD + mergeSameBodies(candidate.source) + '\n' + replacement.slice(end)
278
387
  const checked = parseSync('parseman-entry-supercompile-replacement-result.js', prefix + transformed)
279
388
  return checked.errors.length === 0 ? transformed : replacement
280
389
  }
@@ -12,15 +12,28 @@
12
12
  * `LOOP`, and a choice or dispatch splits its weight evenly across its arms. The
13
13
  * weights are propagated `ROUNDS` times, so a recursive cycle saturates at `CAP`
14
14
  * instead of diverging. Sites are then taken greedily by weight per emitted byte.
15
- * A build given a corpus (`assemblyProfile`) ranks by measured calls instead.
15
+ *
16
+ * A build given a corpus (`assemblyProfile`) ranks by measured calls, and takes
17
+ * sites connected: every call from an emitted site to a closure site, or back,
18
+ * crosses the engine boundary through a wrapper, so a hot site beside the emitted
19
+ * ones is worth more than the same site alone. Ranking by calls per byte alone
20
+ * leaves islands: on Jess's Less grammar at budget 0.5, it made 6.7% to 9.0% more calls
21
+ * per parse than the static ranking.
16
22
  */
17
23
  import { childSlots } from './child-slots.ts'
18
- import { continuationGate } from './late-frame.ts'
19
- import { OP_CHOICE, OP_CONT, OP_DISPATCH, OP_GAPGATE, OP_REP, OP_REPV } from './ops.ts'
24
+ import { OP_CHOICE, OP_CONT, OP_DISPATCH, OP_GAPGATE, OP_REP, OP_REPV, OP_SEED } from './ops.ts'
20
25
 
21
26
  const LOOP = 8
22
27
  const ROUNDS = 60
23
28
  const CAP = 1e12
29
+ /**
30
+ * What a crossing a site removes counts for, against one call of the site itself.
31
+ * Better than no weight, on every file and budget measured with real builds of
32
+ * Jess's grammars, but not tuned: 1 and 16 were only tried on a prototype, 2 and 8
33
+ * were never tried, and only 4 has been built and measured.
34
+ */
35
+ const CROSSING_WEIGHT = 4
36
+ const TAKEN = -1
24
37
 
25
38
  /**
26
39
  * The sites to emit: highest weight per byte first, stopping at the first that
@@ -39,14 +52,8 @@ export function selectHotSites(
39
52
  ): Set<number> {
40
53
  const selected = new Set<number>()
41
54
  if (budget <= 0) return selected
42
- const weight = profile === undefined ? staticWeights(code, entries) : undefined
43
- const w = (ip: number): number => (weight !== undefined ? weight.get(ip) : profile![ip]) ?? 0
44
- // Ties (saturated cycles) break by site offset, so a build is deterministic.
45
- const ranked = [...siteBytes.keys()].sort((a, b) =>
46
- w(b) / siteBytes.get(b)! - w(a) / siteBytes.get(a)! || a - b)
47
55
  let spent = 0
48
- for (const ip of ranked) {
49
- if (w(ip) === 0) break
56
+ for (const ip of profile === undefined ? staticRanking(code, entries, siteBytes) : connectedRanking(code, siteBytes, profile)) {
50
57
  spent += siteBytes.get(ip)!
51
58
  if (spent > budget) break
52
59
  selected.add(ip)
@@ -54,6 +61,82 @@ export function selectHotSites(
54
61
  return selected
55
62
  }
56
63
 
64
+ /**
65
+ * The sites the function for `ip` calls, as `emit-assembly.ts` lowers it. An `OP_CONT`
66
+ * with a body is a late frame, lowered with its wrapper in one function, calling its
67
+ * corner and its tail. With no body, the emitter can't decide there and it's an alias
68
+ * to its wrapper, as `OP_SEED` is to its corner. A gap gate has no body either, so its
69
+ * item counts as the repeat's own child, and adding the row moves no other site's
70
+ * estimate (`gap-gate.ts`).
71
+ */
72
+ export function edgesAsLowered(code: readonly number[], siteBytes: ReadonlyMap<number, number>, ip: number, out: number[]): void {
73
+ out.length = 0
74
+ if (code[ip] !== OP_CONT) childSlots(code, ip, out)
75
+ else if (siteBytes.has(ip)) out.push(code[ip + 1]!, code[ip + 3]!)
76
+ else out.push(code[ip + 2]!)
77
+ for (let i = 0; i < out.length; i++) {
78
+ let row = out[i]!
79
+ for (;;) {
80
+ const op = code[row]
81
+ if (op === OP_GAPGATE || op === OP_SEED) row = code[row + 1]!
82
+ else if (op === OP_CONT && !siteBytes.has(row)) row = code[row + 2]!
83
+ else break
84
+ }
85
+ out[i] = row
86
+ }
87
+ }
88
+
89
+ /** The sites by estimated weight per byte. Ties (saturated cycles) break by site
90
+ * offset, so a build is deterministic. */
91
+ function staticRanking(code: readonly number[], entries: readonly number[], siteBytes: ReadonlyMap<number, number>): number[] {
92
+ const weight = staticWeights(code, entries, siteBytes)
93
+ const w = (ip: number): number => weight.get(ip) ?? 0
94
+ return [...siteBytes.keys()].filter(ip => w(ip) > 0).sort((a, b) => w(b) / siteBytes.get(b)! - w(a) / siteBytes.get(a)! || a - b)
95
+ }
96
+
97
+ /**
98
+ * The sites the corpus ran, each next one being the best by calls plus the crossings
99
+ * it removes, per byte. A crossing with a neighbour is estimated by the calls of
100
+ * whichever is the callee, since the profile counts sites, not edges. Nothing bounds
101
+ * the sum: a callee with N emitted callers gains N crossings' worth of its own calls,
102
+ * and `sequence(g.A, g.A)` counts twice. The order doesn't depend on the budget, so a
103
+ * larger budget's selection is still a superset.
104
+ *
105
+ * ponytail: rescans every site per pick, O(n^2) in sites the corpus ran. 3 ms of a
106
+ * 50 s build on Jess's Less grammar. A heap if a grammar's ever takes seconds.
107
+ */
108
+ function* connectedRanking(code: readonly number[], siteBytes: ReadonlyMap<number, number>, calls: ArrayLike<number>): Generator<number> {
109
+ // Ascending, so the first best a scan meets is the lowest: a tie breaks by site.
110
+ const ran = [...siteBytes.keys()].filter(ip => calls[ip]! > 0).sort((a, b) => a - b)
111
+ const slot = new Map(ran.map((ip, i) => [ip, i]))
112
+ const bytes = ran.map(ip => siteBytes.get(ip)!)
113
+ const worth = ran.map(ip => calls[ip]!)
114
+ const score = worth.map((value, i) => value / bytes[i]!)
115
+ const crossings: [neighbour: number, gain: number][][] = ran.map(() => [])
116
+ const kids: number[] = []
117
+ ran.forEach((ip, from) => {
118
+ edgesAsLowered(code, siteBytes, ip, kids)
119
+ for (const callee of kids) {
120
+ const to = slot.get(callee)
121
+ if (to === undefined) continue
122
+ const gain = CROSSING_WEIGHT * calls[callee]!
123
+ crossings[from]!.push([to, gain])
124
+ crossings[to]!.push([from, gain])
125
+ }
126
+ })
127
+ for (let left = ran.length; left > 0; left--) {
128
+ let best = 0
129
+ for (let i = 1; i < score.length; i++) if (score[i]! > score[best]!) best = i
130
+ score[best] = TAKEN
131
+ yield ran[best]!
132
+ for (const [neighbour, gain] of crossings[best]!) {
133
+ if (score[neighbour] === TAKEN) continue
134
+ worth[neighbour] = worth[neighbour]! + gain
135
+ score[neighbour] = worth[neighbour]! / bytes[neighbour]!
136
+ }
137
+ }
138
+ }
139
+
57
140
  /**
58
141
  * The largest prefix of the ranking whose real source fits `ceiling`. `siteBytes`
59
142
  * leaves out what sites add around their bodies, so the estimate can overshoot:
@@ -81,30 +164,19 @@ export function fitHotSites(
81
164
  return undefined
82
165
  }
83
166
 
84
- function staticWeights(code: readonly number[], entries: readonly number[]): Map<number, number> {
167
+ function staticWeights(code: readonly number[], entries: readonly number[], siteBytes: ReadonlyMap<number, number>): Map<number, number> {
85
168
  let weight = new Map<number, number>()
86
169
  const kids: number[] = []
87
170
  for (let round = 0; round < ROUNDS; round++) {
88
171
  const next = new Map<number, number>()
89
172
  for (const ip of entries) next.set(ip, 1)
90
173
  for (const [ip, w] of weight) {
91
- kids.length = 0
92
- childSlots(code, ip, kids)
174
+ edgesAsLowered(code, siteBytes, ip, kids)
93
175
  const op = code[ip]
94
- // A late frame is lowered with its wrapper in one function, calling these two.
95
- if (op === OP_CONT && continuationGate(code, code[ip + 3]!) !== undefined) {
96
- kids.length = 0
97
- kids.push(code[ip + 1]!, code[ip + 3]!)
98
- }
99
176
  const scale = op === OP_REP || op === OP_REPV ? LOOP
100
177
  : op === OP_CHOICE || op === OP_DISPATCH ? 1 / Math.max(1, kids.length)
101
178
  : 1
102
- for (const k of kids) {
103
- // A gap gate has no body: weigh its item as the repeat's own child, so
104
- // adding the row moves no other site's estimate (`gap-gate.ts`).
105
- const kid = code[k] === OP_GAPGATE ? code[k + 1]! : k
106
- next.set(kid, Math.min(CAP, (next.get(kid) ?? 0) + w * scale))
107
- }
179
+ for (const kid of kids) next.set(kid, Math.min(CAP, (next.get(kid) ?? 0) + w * scale))
108
180
  }
109
181
  weight = next
110
182
  }
package/src/version.ts CHANGED
@@ -13,4 +13,4 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export const PARSEMAN_VERSION = '0.61.5'
16
+ export const PARSEMAN_VERSION = '0.61.6'