Skip to content

Commit 7d55262

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 30dfa0b commit 7d55262

File tree

2 files changed

+97
-9
lines changed

2 files changed

+97
-9
lines changed

Sources/Rego/IREvaluator.swift

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ 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+
self.callMemo = MemoStack.init()
133+
}
127134

128135
func withIncrementedCallDepth() -> IREvaluationContext {
129136
var ctx = self
@@ -911,14 +918,17 @@ func evalBlock(
911918
var newLocals = currentScopePtr.v.locals
912919
newLocals[stmt.local] = patched
913920
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-
)
921921

922+
let blockResult = try await ctx.callMemo.withPush {
923+
// Start executing the block on the new scope we just pushed
924+
return try await evalBlock(
925+
withContext: ctx,
926+
framePtr: framePtr,
927+
caller: statement,
928+
block: stmt.block
929+
)
930+
}
931+
922932
// Squash locals from the child frame back into the current frame.
923933
// Overlay the original (non-patched) value, keep the other side-effects.
924934
let _ = try popAndSquash(frame: framePtr)
@@ -957,6 +967,13 @@ private func evalCall(
957967
args: [IR.Operand],
958968
isDynamic: Bool = false
959969
) async throws -> AST.RegoValue {
970+
// Check memo cache if applicable to save repeated evaluation time for rules
971+
let shouldMemoize = args.count == 2 // Currently support _rules_, not _functions_
972+
let sig = InvocationKey(funcName: funcName, args: args)
973+
if shouldMemoize, let cachedResult = ctx.callMemo[sig] {
974+
return cachedResult
975+
}
976+
960977
var argValues: [AST.RegoValue] = []
961978
for arg in args {
962979
// Note: we do not enforce that args are defined here, it appears
@@ -974,24 +991,35 @@ private func evalCall(
974991
return .undefined
975992
}
976993

977-
return try await callPlanFunc(
994+
let result = try await callPlanFunc(
978995
ctx: ctx,
979996
frame: frame,
980997
caller: caller,
981998
funcName: funcName,
982999
args: argValues
9831000
)
1001+
1002+
if shouldMemoize {
1003+
ctx.callMemo[sig] = result
1004+
}
1005+
return result
9841006
}
9851007

9861008
// Handle plan-defined functions first
9871009
if ctx.policy.funcs[funcName] != nil {
988-
return try await callPlanFunc(
1010+
let result = try await callPlanFunc(
9891011
ctx: ctx,
9901012
frame: frame,
9911013
caller: caller,
9921014
funcName: funcName,
9931015
args: argValues
9941016
)
1017+
1018+
if shouldMemoize {
1019+
ctx.callMemo[sig] = result
1020+
}
1021+
1022+
return result
9951023
}
9961024

9971025
// 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+
internal struct InvocationKey: Hashable {
10+
let funcName: String
11+
let args: [IR.Operand]
12+
}
13+
14+
/// MemoCache is a memoization cache of plan invocations
15+
internal typealias MemoCache = [InvocationKey: AST.RegoValue]
16+
17+
/// MemoStack is a stack of MemoCaches
18+
final internal 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)