Skip to content

Commit 31dbfa0

Browse files
authored
wasm: Maintain rightmost failure position during parse (#523)
- Add a global (`rightmostFailurePos`) to track the rightmost failure position - Rule eval functions maintain the value in a local (`failurePos`) which is initialized from the global, and pushed back (if applicable) at the end of the rule body. - Changes the representation of the memo entries and CST nodes, to allow `failurePos` to be memoized. - Add a property-based test (using [fast-check](https://fast-check.dev/)) for getRightmostFailurePosition
1 parent d5d3c8c commit 31dbfa0

11 files changed

Lines changed: 746 additions & 246 deletions

File tree

packages/miniohm-js/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ export class WasmMatcher {
127127
buf[written] = 0xff; // Mark end of input with an invalid UTF-8 character.
128128
return written;
129129
}
130+
131+
getRightmostFailurePosition() {
132+
return this._instance.exports.rightmostFailurePos.value;
133+
}
130134
}
131135

132136
class CstNode {
@@ -169,7 +173,7 @@ class CstNode {
169173
get children() {
170174
const children = [];
171175
for (let i = 0; i < this.count; i++) {
172-
const slotOffset = this._base + 12 + i * 4;
176+
const slotOffset = this._base + 16 + i * 4;
173177
children.push(
174178
new CstNode(this._ruleNames, this._view, this._view.getUint32(slotOffset, true)),
175179
);

packages/wasm/TODO.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,37 @@
11
## TODOs
22

33
- [x] Include a map of rule name to ruleId in the module.
4-
- [ ] Implicit space skipping
4+
- [x] Implicit space skipping
55
- [ ] Error handling
66
- [x] NonterminalNodes should keep track of the rule
77
- [ ] When iteration contains a sequence, the children are flattened into the iter node.
88
- [x] Basic parameterized rules
99
- [x] Parameterized rules with >3 params
1010
- [x] Parameters that aren't terminals
1111
- [x] Memoization for parameterized rules
12-
- [ ] Avoid unnecessary dispatch in generalized rules
13-
- [ ] Avoid duplicate lifted rules.
1412
- [x] Support direct left recursion.
15-
- [ ] Handle left recursion detection at grammar parse time.
1613
- [x] Separate API for _creating_ the Wasm module from the WasmMatcher interface.
1714
- [x] Implement a proper CLI.
15+
16+
Cleanups:
17+
18+
- [ ] Handle left recursion detection at grammar parse time.
1819
- [ ] Handle non-memoization of inline rules at grammar parse time
20+
- [ ] Move to a failureOffset in memo entries
21+
- [ ] Add assertions for any known input size limitations.
22+
23+
Optimizations:
24+
25+
- [ ] Avoid unnecessary dispatch in generalized rules
26+
- [ ] Avoid duplicate lifted rules.
27+
- [ ] Compressed (32-bit) header for Nonterminal nodes in common case
28+
- [ ] Compressed (inline 32-bit) repr for Terminal nodes
29+
- [ ] Proper preallocated nodes (incl. failurePos) for common cases
1930

2031
## Limitations
2132

2233
- The input is assumed to be no bigger than 64k.
2334
- For the memo table, we assume that there are no more than 256 rules in the grammar.
24-
- Parameterized rules only support up to 3 parameters, and no memoization.
25-
- Parameters must be terminals.
2635

2736
## Unanswered questions
2837

packages/wasm/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"assemblyscript": "^0.27.36",
2727
"ava": "^6.2.0",
2828
"esbuild": "^0.25.5",
29+
"fast-check": "^4.2.0",
2930
"fast-glob": "^3.3.3",
3031
"liquid-html-parser": "link:@shopify/liquid-html-parser",
3132
"mitata": "^1.0.34",

packages/wasm/runtime/ohmRuntime.ts

Lines changed: 130 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
type Result = i32;
1+
type ApplyResult = bool;
22

33
declare function fillInputBuffer(offset: i32, maxLen: i32): i32;
44
declare function printI32(val: i32): void;
@@ -13,27 +13,57 @@ declare function isRuleSyntactic(ruleId: i32): bool;
1313
@inline const STACK_START_OFFSET: usize = WASM_PAGE_SIZE;
1414
@inline const MAX_INPUT_LEN_BYTES: usize = 64 * 1024;
1515

16-
// Note: the rule evaluation functions use a different representation.
17-
// They return non-zero for success and zero for failure.
18-
@inline const EMPTY: Result = 0;
19-
@inline const FAIL: Result = 0xfffffff0;
20-
@inline const UNUSED_LR_BOMB: Result = FAIL | 0x1;
21-
@inline const USED_LR_BOMB: Result = FAIL | 0x3;
22-
23-
@inline const CST_NODE_OVERHEAD: usize = 12;
16+
// CST nodes
17+
@inline const CST_NODE_OVERHEAD: usize = 16;
18+
@inline const NODE_TYPE_TERMINAL: i32 = -1;
2419
@inline const NODE_TYPE_ITERATION: i32 = -2;
2520

21+
// Memo table entries
22+
type MemoEntry = i32;
23+
24+
@inline const EMPTY: MemoEntry = 0;
25+
26+
// Low bit: failure flag.
27+
// Rest: failurePos (signed int, 31 bits).
28+
@inline const MEMO_FAILURE_FLAG: MemoEntry = 0x1;
29+
30+
// Not: left recursion bombs never include failurePos.
31+
// We need to be careful that a true failure w/ failurePos can't produce
32+
// the same value. Because failurePos >= -1, we can use -2 and -3.
33+
// TODO: Use failureOffset (unsigned) instead? That's what we do in JS.
34+
@inline const UNUSED_LR_BOMB: MemoEntry = (-2 << 1) | MEMO_FAILURE_FLAG;
35+
@inline const USED_LR_BOMB: MemoEntry = (-3 << 1) | MEMO_FAILURE_FLAG
36+
37+
// The result of a raw rule evaluation function.
38+
// Low bit: RULE_EVAL_SUCCESS_FLAG
39+
// Rest: failurePos (signed int, 31 bits).
40+
type RuleEvalResult = i32;
41+
42+
@inline const RULE_EVAL_SUCCESS_FLAG = 1;
43+
2644
// Shared globals
2745
let pos: i32 = 0;
46+
47+
// The rightmost position at which a leaf (Terminal, etc.) failed to match.
48+
let rightmostFailurePos: i32 = 0;
49+
2850
let sp: usize = 0;
2951
let bindings: Array<i32> = new Array<i32>();
3052

31-
@inline function memoTableGet(memoPos: usize, ruleId: i32): Result {
32-
return load<Result>(memoPos * MEMO_COL_SIZE_BYTES + ruleId * sizeof<Result>(), MEMO_START_OFFSET);
53+
@inline function max<T>(a: T, b: T): T {
54+
return a > b ? a : b;
55+
}
56+
57+
@inline function memoEntryForFailure(failurePos: i32): MemoEntry {
58+
return (failurePos << 1) | MEMO_FAILURE_FLAG;
59+
}
60+
61+
@inline function memoTableGet(memoPos: usize, ruleId: i32): MemoEntry {
62+
return load<MemoEntry>(memoPos * MEMO_COL_SIZE_BYTES + ruleId * sizeof<MemoEntry>(), MEMO_START_OFFSET);
3363
}
3464

35-
@inline function memoTableSet(memoPos: usize, ruleId: i32, value: Result): void {
36-
store<Result>(memoPos * MEMO_COL_SIZE_BYTES + ruleId * sizeof<Result>(), value, MEMO_START_OFFSET);
65+
@inline function memoTableSet(memoPos: usize, ruleId: i32, value: MemoEntry): void {
66+
store<MemoEntry>(memoPos * MEMO_COL_SIZE_BYTES + ruleId * sizeof<MemoEntry>(), value, MEMO_START_OFFSET);
3767
}
3868

3969
@inline function cstGetCount(ptr: usize): i32 {
@@ -60,28 +90,27 @@ let bindings: Array<i32> = new Array<i32>();
6090
store<i32>(ptr, t, 8);
6191
}
6292

63-
@inline function memoizeResult(memoPos: usize, ruleId: i32, result: Result): void {
64-
memoTableSet(memoPos, ruleId, result);
93+
@inline function cstGetFailurePos(ptr: usize): i32 {
94+
return load<i32>(ptr, 12);
6595
}
6696

67-
@inline function isFailure(result: Result): bool {
68-
return result < 0;
97+
@inline function cstSetFailurePos(ptr: usize, pos: i32): void {
98+
store<i32>(ptr, pos, 12);
6999
}
70100

71-
function useMemoizedResult(ruleId: i32, result: Result): Result {
72-
if (result === UNUSED_LR_BOMB) {
73-
memoTableSet(pos, ruleId, USED_LR_BOMB);
74-
return 0;
75-
} else if (isFailure(result)) {
76-
return 0;
101+
function useMemoizedResult(ruleId: i32, result: MemoEntry): ApplyResult {
102+
if (result & MEMO_FAILURE_FLAG) {
103+
if (result === UNUSED_LR_BOMB) {
104+
memoTableSet(pos, ruleId, USED_LR_BOMB);
105+
} else {
106+
rightmostFailurePos = max(rightmostFailurePos, result >> 1);
107+
}
108+
return false;
77109
}
78110
pos += cstGetMatchLength(result);
111+
rightmostFailurePos = max(rightmostFailurePos, cstGetFailurePos(result));
79112
bindings.push(result);
80-
return result;
81-
}
82-
83-
function hasMemoizedResult(ruleId: i32): boolean {
84-
return memoTableGet(pos, ruleId) !== 0;
113+
return true;
85114
}
86115

87116
@inline function maybeSkipSpaces(ruleId: i32): void {
@@ -93,12 +122,18 @@ function hasMemoizedResult(ruleId: i32): boolean {
93122
}
94123
}
95124

96-
export function match(startRuleId: i32): Result {
97-
// (Re-)initialize globals, clear memo table.
125+
function resetParsingState(): void {
98126
pos = 0;
127+
rightmostFailurePos = -1;
99128
sp = STACK_START_OFFSET;
129+
heap.reset();
130+
100131
bindings = new Array<i32>();
101132
memory.fill(MEMO_START_OFFSET, 0, MEMO_COL_SIZE_BYTES * MAX_INPUT_LEN_BYTES);
133+
}
134+
135+
export function match(startRuleId: i32): ApplyResult {
136+
resetParsingState();
102137

103138
// Get the input and do the match.
104139
let inputLen = fillInputBuffer(0, i32(WASM_PAGE_SIZE));
@@ -107,98 +142,125 @@ export function match(startRuleId: i32): Result {
107142
const succeeded = evalApply0(startRuleId) !== 0;
108143
if (succeeded) {
109144
maybeSkipSpaces(startRuleId);
145+
// printI32(heap.alloc(8) - __heap_base); // Print heap usage.
146+
// TODO: Do we need to update rightmostFailurePos here?
110147
return inputLen === pos;
111148
}
112-
return 0;
149+
150+
return false;
113151
}
114152

115-
@inline function evalRuleBody(ruleId: i32): Result {
116-
return call_indirect<Result>(ruleId);
153+
@inline function evalRuleBody(ruleId: i32): RuleEvalResult {
154+
return call_indirect<RuleEvalResult>(ruleId);
117155
}
118156

119-
export function evalApplyGeneralized(ruleId: i32, caseIdx: i32): Result {
157+
// Extracts the local failure position from a RuleEvalResult.
158+
// If it's greater than the global rightmostFailurePos, it updates it.
159+
// Returns the local failure position.
160+
@inline function maybeUpdateRightmostFailurePos(result: RuleEvalResult): i32 {
161+
const failurePos = result >> 1;
162+
rightmostFailurePos = max(rightmostFailurePos, failurePos);
163+
return failurePos;
164+
}
165+
166+
// Evaluates a generalized rule. Identical to evalApplyNoMemo0, but includes
167+
// the caseIdx.
168+
export function evalApplyGeneralized(ruleId: i32, caseIdx: i32): ApplyResult {
120169
const origPos = pos;
121170
const origNumBindings = bindings.length;
122-
if (call_indirect<Result>(ruleId, caseIdx)) {
123-
return newNonterminalNode(origPos, pos, ruleId, origNumBindings);
171+
const result = call_indirect<RuleEvalResult>(ruleId, caseIdx)
172+
const failurePos = maybeUpdateRightmostFailurePos(result);
173+
if (result & RULE_EVAL_SUCCESS_FLAG) {
174+
newNonterminalNode(origPos, pos, ruleId, origNumBindings, failurePos);
175+
return true;
124176
}
125-
return 0;
177+
return false;
126178
}
127179

128-
export function evalApplyNoMemo0(ruleId: i32): Result {
180+
export function evalApplyNoMemo0(ruleId: i32): ApplyResult {
129181
const origPos = pos;
130182
const origNumBindings = bindings.length;
131-
if (evalRuleBody(ruleId)) {
132-
return newNonterminalNode(origPos, pos, ruleId, origNumBindings);
183+
let result = evalRuleBody(ruleId);
184+
const failurePos = maybeUpdateRightmostFailurePos(result);
185+
if (result & RULE_EVAL_SUCCESS_FLAG) {
186+
newNonterminalNode(origPos, pos, ruleId, origNumBindings, failurePos);
187+
return true;
133188
}
134-
return 0;
189+
return false;
135190
}
136191

137-
export function evalApply0(ruleId: i32): Result {
138-
let result = memoTableGet(pos, ruleId);
139-
if (result !== 0) {
140-
return useMemoizedResult(ruleId, result);
192+
export function evalApply0(ruleId: i32): ApplyResult {
193+
const memo = memoTableGet(pos, ruleId);
194+
if (memo !== 0) {
195+
return useMemoizedResult(ruleId, memo);
141196
}
142197
const origPos = pos;
143-
let origNumBindings = bindings.length;
144-
memoizeResult(origPos, ruleId, UNUSED_LR_BOMB);
145-
let succeeded: i32 = evalRuleBody(ruleId);
198+
const origNumBindings = bindings.length;
199+
memoTableSet(origPos, ruleId, UNUSED_LR_BOMB);
200+
201+
const result = evalRuleBody(ruleId);
202+
const failurePos = maybeUpdateRightmostFailurePos(result);
146203

147204
// Straight failure — record a clean failure in the memo table.
148-
if (!succeeded) {
149-
memoizeResult(origPos, ruleId, FAIL);
150-
return 0;
205+
if ((result & RULE_EVAL_SUCCESS_FLAG) == 0) {
206+
memoTableSet(origPos, ruleId, memoEntryForFailure(failurePos));
207+
return false;
151208
}
152209

153210
if (memoTableGet(origPos, ruleId) === USED_LR_BOMB) {
154-
return handleLeftRecursion(origPos, ruleId, origNumBindings);
211+
return handleLeftRecursion(origPos, ruleId, origNumBindings, failurePos);
155212
}
156-
157213
// No left recursion — memoize and return.
158-
result = newNonterminalNode(origPos, pos, ruleId, origNumBindings);
159-
memoizeResult(origPos, ruleId, result);
160-
return result;
214+
const node = newNonterminalNode(origPos, pos, ruleId, origNumBindings, failurePos);
215+
memoTableSet(origPos, ruleId, <MemoEntry>node);
216+
return true;
161217
}
162218

163-
export function handleLeftRecursion(origPos: usize, ruleId: i32, origNumBindings: i32): Result {
219+
export function handleLeftRecursion(origPos: usize, ruleId: i32, origNumBindings: i32, failurePos: i32): ApplyResult {
164220
let maxPos: i32;
165-
let result: Result;
166-
let succeeded: i32;
221+
let node: usize;
222+
let succeeded: bool;
167223
do {
168224
// The current result is the best one -- record it.
169225
maxPos = pos;
170-
result = newNonterminalNode(origPos, pos, ruleId, origNumBindings);
171-
memoizeResult(origPos, ruleId, result);
226+
rightmostFailurePos = max(rightmostFailurePos, failurePos);
227+
node = newNonterminalNode(origPos, pos, ruleId, origNumBindings, failurePos);
228+
memoTableSet(origPos, ruleId, <MemoEntry>node);
172229

173230
// Reset and try to improve on the current best.
174231
pos = origPos;
175232
bindings.length = origNumBindings;
176-
succeeded = evalRuleBody(ruleId);
233+
const result = evalRuleBody(ruleId);
234+
succeeded = (result & RULE_EVAL_SUCCESS_FLAG) != 0;
235+
failurePos = result >> 1;
177236
} while (succeeded && pos > maxPos);
178237

179238
pos = maxPos;
239+
180240
bindings.length = origNumBindings + 1;
181-
bindings[origNumBindings] = result;
241+
bindings[origNumBindings] = node;
182242
return succeeded;
183243
}
184244

185245
export function newTerminalNode(startIdx: i32, endIdx: i32): usize {
186246
const ptr = heap.alloc(CST_NODE_OVERHEAD);
187247
cstSetCount(ptr, 0);
188248
cstSetMatchLength(ptr, endIdx - startIdx);
189-
cstSetType(ptr, -1);
249+
cstSetType(ptr, NODE_TYPE_TERMINAL);
250+
cstSetFailurePos(ptr, 0);
190251
bindings.push(ptr);
191252
return ptr;
192253
}
193254

194255
// Create an internal (non-leaf) node (IterationNode or NonterminalNode).
195-
@inline function newNonLeafNodeWithType(startIdx: i32, endIdx: i32, type: i32, origNumBindings: i32): usize {
256+
@inline function newNonLeafNode(startIdx: i32, endIdx: i32, type: i32, origNumBindings: i32, failurePos: i32): usize {
196257
const bindingsLen = bindings.length;
197258
const numChildren = bindingsLen - origNumBindings;
198259
const ptr = heap.alloc(CST_NODE_OVERHEAD + numChildren * 4);
199260
cstSetCount(ptr, numChildren);
200261
cstSetMatchLength(ptr, endIdx - startIdx);
201262
cstSetType(ptr, type);
263+
cstSetFailurePos(ptr, failurePos);
202264
for (let i = 0; i < numChildren; i++) {
203265
store<i32>(ptr + CST_NODE_OVERHEAD + i * 4, bindings[bindingsLen - numChildren + i]);
204266
}
@@ -207,12 +269,12 @@ export function newTerminalNode(startIdx: i32, endIdx: i32): usize {
207269
return ptr;
208270
}
209271

210-
export function newNonterminalNode(startIdx: i32, endIdx: i32, ruleId: i32, origNumBindings: i32): usize {
211-
return newNonLeafNodeWithType(startIdx, endIdx, ruleId, origNumBindings);
272+
export function newNonterminalNode(startIdx: i32, endIdx: i32, ruleId: i32, origNumBindings: i32, failurePos: i32): usize {
273+
return newNonLeafNode(startIdx, endIdx, ruleId, origNumBindings, failurePos);
212274
}
213275

214276
export function newIterationNode(startIdx: i32, endIdx: i32, origNumBindings: i32): usize {
215-
return newNonLeafNodeWithType(startIdx, endIdx, NODE_TYPE_ITERATION, origNumBindings);
277+
return newNonLeafNode(startIdx, endIdx, NODE_TYPE_ITERATION, origNumBindings, -1);
216278
}
217279

218280
export function getBindingsLength(): i32 {

0 commit comments

Comments
 (0)