Skip to content

Commit 3811c03

Browse files
committed
perf: memoize IR plan calls to reduce reevaluation cost
Adds a per-evaluation call memoization stack to the IREvaluationContext. This currently supports rules, not arbitrary functions, based on arity. For a policy with many repeated calls, we see evaluation time drop from ~241ms to ~2ms. No regression in compliance tests. Signed-off-by: Oren Shomron <[email protected]>
1 parent 59088ed commit 3811c03

File tree

2 files changed

+96
-9
lines changed

2 files changed

+96
-9
lines changed

Sources/Rego/IREvaluator.swift

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ internal struct IREvaluationContext {
124124
var policy: IndexedIRPolicy
125125
var maxCallDepth: Int = 16_384
126126
var callDepth: Int = 0
127+
var callMemo = MemoStack() // shared stack of memoCache structs
128+
129+
init(ctx: EvaluationContext, policy: IndexedIRPolicy) {
130+
self.ctx = ctx
131+
self.policy = policy
132+
}
127133

128134
func withIncrementedCallDepth() -> IREvaluationContext {
129135
var ctx = self
@@ -911,13 +917,16 @@ func evalBlock(
911917
var newLocals = currentScopePtr.v.locals
912918
newLocals[stmt.local] = patched
913919
currentScopePtr = framePtr.v.pushScope(locals: newLocals)
914-
// Start executing the block on the new scope we just pushed
915-
let blockResult = try await evalBlock(
916-
withContext: ctx,
917-
framePtr: framePtr,
918-
caller: statement,
919-
block: stmt.block
920-
)
920+
921+
let blockResult = try await ctx.callMemo.withPush {
922+
// Start executing the block on the new scope we just pushed
923+
return try await evalBlock(
924+
withContext: ctx,
925+
framePtr: framePtr,
926+
caller: statement,
927+
block: stmt.block
928+
)
929+
}
921930

922931
// Squash locals from the child frame back into the current frame.
923932
// Overlay the original (non-patched) value, keep the other side-effects.
@@ -957,6 +966,13 @@ private func evalCall(
957966
args: [IR.Operand],
958967
isDynamic: Bool = false
959968
) async throws -> AST.RegoValue {
969+
// Check memo cache if applicable to save repeated evaluation time for rules
970+
let shouldMemoize = args.count == 2 // Currently support _rules_, not _functions_
971+
let sig = InvocationKey(funcName: funcName, args: args)
972+
if shouldMemoize, let cachedResult = ctx.callMemo[sig] {
973+
return cachedResult
974+
}
975+
960976
var argValues: [AST.RegoValue] = []
961977
for arg in args {
962978
// Note: we do not enforce that args are defined here, it appears
@@ -974,24 +990,35 @@ private func evalCall(
974990
return .undefined
975991
}
976992

977-
return try await callPlanFunc(
993+
let result = try await callPlanFunc(
978994
ctx: ctx,
979995
frame: frame,
980996
caller: caller,
981997
funcName: funcName,
982998
args: argValues
983999
)
1000+
1001+
if shouldMemoize {
1002+
ctx.callMemo[sig] = result
1003+
}
1004+
return result
9841005
}
9851006

9861007
// Handle plan-defined functions first
9871008
if ctx.policy.funcs[funcName] != nil {
988-
return try await callPlanFunc(
1009+
let result = try await callPlanFunc(
9891010
ctx: ctx,
9901011
frame: frame,
9911012
caller: caller,
9921013
funcName: funcName,
9931014
args: argValues
9941015
)
1016+
1017+
if shouldMemoize {
1018+
ctx.callMemo[sig] = result
1019+
}
1020+
1021+
return result
9951022
}
9961023

9971024
// Handle built-in functions last

Sources/Rego/Memo.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import AST
2+
import Foundation
3+
import IR
4+
5+
/// InvocationKey is a key for memoizing an IR function call invocation.
6+
/// Note we capture the arguments as unresolved operands and not resolved values,
7+
/// as hashing the values was proving extremely expensive. We instead rely on the
8+
/// invariant the the plan / evaluator will not modify a local after it has been initally set.
9+
struct InvocationKey: Hashable {
10+
let funcName: String
11+
let args: [IR.Operand]
12+
}
13+
14+
/// MemoCache is a memoization cache of plan invocations
15+
typealias MemoCache = [InvocationKey: AST.RegoValue]
16+
17+
/// MemoStack is a stack of MemoCaches
18+
final class MemoStack {
19+
var stack: [MemoCache] = []
20+
}
21+
22+
extension MemoStack {
23+
/// Get and set values on the cache at the top of the memo stack.
24+
subscript(key: InvocationKey) -> AST.RegoValue? {
25+
get {
26+
guard !self.stack.isEmpty else {
27+
return nil
28+
}
29+
return self.stack[self.stack.count - 1][key]
30+
}
31+
set {
32+
if self.stack.isEmpty {
33+
self.stack.append(MemoCache.init())
34+
}
35+
self.stack[self.stack.count - 1][key] = newValue
36+
}
37+
}
38+
39+
func push() {
40+
self.stack.append(MemoCache.init())
41+
}
42+
43+
func pop() {
44+
guard !self.stack.isEmpty else {
45+
return
46+
}
47+
self.stack.removeLast()
48+
}
49+
50+
/// withPush returns the result of calling the provided closure with
51+
/// a fresh memoCache pushed on the stack. The memoCache will only be
52+
/// active during that call, and discarded when it completes.
53+
func withPush<T>(_ body: () async throws -> T) async rethrows -> T {
54+
self.push()
55+
defer {
56+
self.pop()
57+
}
58+
return try await body()
59+
}
60+
}

0 commit comments

Comments
 (0)