Skip to content

Compile dikastes/collector policies once instead of interpreting per flow (rebase of #13267) - #13898

Draft
dimitri-nicolo wants to merge 3 commits into
projectcalico:masterfrom
dimitri-nicolo:dimitri-pmreq954-compile-rebase
Draft

Compile dikastes/collector policies once instead of interpreting per flow (rebase of #13267)#13898
dimitri-nicolo wants to merge 3 commits into
projectcalico:masterfrom
dimitri-nicolo:dimitri-pmreq954-compile-rebase

Conversation

@dimitri-nicolo

Copy link
Copy Markdown
Contributor

Description

Rebase of #13267 (Shaun Crampton's "Compile dikastes/collector policies once instead of interpreting per flow") onto current master, opened as a draft for the collector policy-evaluation work in CORE-13316. Shaun's two logical changes keep his authorship; the eleven original commits are squashed into two because seven of them rewrote check.go, which master had since rewritten twice (#13416 scope and error signature, #13408 cheapest-first), and a commit-by-commit rebase would have resolved the same conflict seven times. The two squashed commits reproduce the #13267 head tree byte for byte before the rebase; the messages name the source SHAs. A third commit adds equivalence tests for the behaviours master gained in the meantime. Shaun: if you would rather pull this into your branch and keep #13267 as the PR, that works just as well; this is here so the epic has something to measure against.

What the change does (unchanged from #13267): a parsed-IP fast path for NET IP sets (IPAddrSet.ContainsIP) with the flow's parsed addresses memoised in requestCache; and per-policy compilation, in which the policy store compiles each policy and profile when it applies the update, through a PolicyCompiler injected at the two wiring sites (dikastes and the felix collector). A compiled policy is a per-rule slice of matcher closures covering only the criteria the rule uses, over values resolved once at compile time; a reverse index recompiles exactly the policies affected when an IP set is replaced.

What the rebase reconciled (please look at these first):

  1. check.goEvaluate(scope, dir, store, ep, flow, traceBuf) merges Make the staged-policy scope of ALP policy evaluation explicit #13416's scope and error return with the PR's reusable trace buffer. The tier walk is shared by both engines and keeps everything from Make the staged-policy scope of ALP policy evaluation explicit #13416 above the engine dispatch: the EnforcedOnly staged skip, the policiesInScope > 0 gate on the end-of-tier action, and the fail-closed INTERNAL for a policy missing from the store. checkTierPolicy returns (action, index, found): the compiled slot when present, else the stored policy interpreted, else found=false and the walk fails closed. Confirm you are happy that "compiled entry present" implies "policy present" (the store empties a slot on remove and on a nil-policy update).
  2. compile.goappendRuleMatchers emits matchers in Evaluate policy rule criteria cheapest-first in Dikastes #13408's cheapest-first order (protocol, source ports, destination ports, source net, destination net, source IP sets, destination IP sets, destination IP+port sets, source identity, destination identity, HTTP last so the malformed-path panic is confined identically), and named-port sets are looked up by the "<IP>,<protocol>:<port>" key introduced by Checker can not evaluate named ports #13174. These two are the rebase's reconciliation, not Shaun's code.
  3. Public API: checker.Evaluate now takes the trace buffer as a sixth parameter; the only caller is felix/collector. If the buffer is not wanted it can be dropped without touching the engine. (app-policy/checker: cache pending-policy verdicts per policy store generation #13897 on the same epic also wraps Evaluate, with a verdict cache; whichever lands second rebases.)
  4. tierDefaultRuleID memo applies only when the first in-scope policy is the tier's first; a staged policy first under EnforcedOnly falls back to building the RuleID. Covered by TestCheckTiersPolicyScope on both engines.
  5. Compile-time warnings (IPSet not found, bad CIDR or selector) use plain log.Warn, not the rate-limited loggers; bounded per compile, but a decision to make.

Numbersgo test ./app-policy/checker/ -run '^$' -bench BenchmarkEvaluate -benchmem -benchtime 20x -cpu 1, min of three runs on one laptop core. These supersede the figures in #13267's description, which predate cheapest-first.

Sub-benchmark master (interpreted) this branch, compiled allocs/op master → compiled speed-up
BaselinePolicyScale/AllSetsPresent 2.05 ms 0.350 ms 51 → 0 5.9×
BaselinePolicyScale/MissingSets 2.01 ms 0.334 ms 261 → 0 (warnings 14/op → 0) 6.0×
BaselinePolicyScale/MatchEarly 0.66 µs 0.13 µs 8 → 0 5.0×
EgressAllowList/TailPort 720 µs 204 µs 20 → 0 3.5×
EgressAllowList/PopularPort 1.31 ms 0.358 ms 6,825 → 0 3.7×
EgressAllowList/Denied 1.95 ms 0.455 ms 10,361 → 0 4.3×

Compiled egress cost is 18 / 33 / 29 ns per rule against 81 / 106 / 109 on master. The interpreted path on this branch is within noise of master.

Testing

go test -count=1 ./app-policy/... ./felix/collector/...     # every package ok (checker, health, policystore, syncher, collector, collector/flowlog, types/counter, types/tuple)
go test -race ./app-policy/checker/ -run 'TestCompile|TestCheckTiersPolicyScope|TestMalformedHTTPPath'   # ok
go vet ./app-policy/... ./felix/collector/...                # clean
golangci-lint run (felix/.golangci.yaml) over app-policy     # 0 issues

The third commit extends TestCompiledPolicyEquivalence (ICMP flow, IPv6 through a NET set, named-port sets on the destination leg and the wrong leg, ranges plus named sets, wide negations, protocol plus ports, protocol plus HTTP, an all-criteria rule) and runs TestCheckTiersPolicyScope, TestCheckStoreReportsWhichPolicyIsMissing, TestEvaluateRecordsStagedPolicyInPendingTraceOnly and TestMalformedHTTPPathOnlyFailsRulesThatReachIt on both engines, and adds BenchmarkEvaluateEgressAllowListCompiled.

Release note:

Felix/Dikastes performance: policies are compiled once when they are received instead of being interpreted for every flow, making user-mode policy evaluation 3.5-6x faster on large policy sets on top of the cheapest-first ordering, with no allocations per evaluation.

AI assistance: The rebase, conflict resolution and added tests were done with Claude Code (Claude Fable 5.1); the engine is Shaun's work from #13267. The author reviewed the reconciled files and ran the tests and benchmarks above.

By opening this PR you take responsibility for every line in it, and you agree to explain the change yourself during review rather than routing review comments back through an agent. See AI_POLICY.md.

🤖 Generated with Claude Code

fasaxc and others added 3 commits September 11, 2026 19:55
The match functions hold the flow's net.IP but IPSet.Contains only takes a
string, so NET sets re-parse the address on every rule that references an IP
set. Add an IPAddrSet fast path (ContainsIP) implemented by ipNetSet, memoize
the parsed per-flow IPs in requestCache, and route address lookups through it.

The parsed-IP memoization tracks fetched-ness explicitly rather than using nil
as its "not fetched yet" sentinel: nil is also a valid value (non-IP
connections, e.g. pipes), and such flows would otherwise call back into the
Flow implementation on every access.

BenchmarkEvaluateBaselinePolicyScale/MissingSets (-cpu 1), measured before
cheapest-first criterion ordering (projectcalico#13408) landed: 3.95ms -> 3.25ms per
evaluation, 5247 -> 499 allocs/op, 108KB -> 33KB/op.

Squash of the first, fourth and fifth commits of projectcalico#13267 (15f7ca0,
4418393, 9ac0d63), rebased onto master after projectcalico#13408, projectcalico#13410 and
projectcalico#13416.
…flow

checker.Evaluate walked every criterion of every rule for every flow,
re-reading the large heap-scattered proto.Rule structs and recomputing
rule-constant values each time (action enum, namespace-match inputs,
selector/CIDR/protocol parses, IP set ID lookups). At the scale seen in a
large production deployment (294 policies x 68 rules, ~3.7k IP sets) one
evaluation cost milliseconds and re-warned 'IPSet not found' per rule per
flow, saturating the felix collector.

Compile each policy/profile once, when the policy store applies its update:
per-rule slices of matcher closures covering only the criteria the rule uses,
over pre-resolved values (IP set objects, parsed selectors and CIDRs,
flattened port ranges, resolved protocol numbers, precomputed namespace
matches, parsed actions). The matchers are emitted in the same cheapest-first
order the interpreted match() evaluates its criteria in (projectcalico#13408): protocol,
ports, CIDRs, IP sets, identity, HTTP last so that a malformed request path
only fails rules that otherwise apply. The store owns the compiled artifacts,
so they die with the double-buffered store on resync; a reverse index (IP set
ID -> referencing policies) recompiles just the affected policies when a full
IPSetUpdate replaces a set's object (deltas mutate the object in place and
need nothing). The compiler is injected into the store by the two wiring
sites (dikastes and the felix collector) to keep the package dependency
checker -> policystore one-way.

The interpreted path remains the reference implementation and the fallback:
policies with no compiled entry (no compiler configured, compile failure, or
the CALICO_DISABLE_POLICY_COMPILATION kill switch) are interpreted per flow
as before, and the compiled path defers to it at debug log level so
per-criterion debug logging is unchanged. The tier walk is shared by both
engines, so the staged-policy scope (projectcalico#13416) applies identically: under
EnforcedOnly staged policies are skipped and a staged-only tier contributes
no end-of-tier action, and a policy missing from the store fails the
evaluation closed whichever engine would have evaluated it.
TestCompiledPolicyEquivalence asserts both engines return identical
(action, index) across the criterion matrix, and the existing checkStore
tests run against both engines.

A missing IP set now warns once at compile time instead of once per rule per
flow.

Endpoints are compiled too: their tiers' policy references and their profile
references become slices, index-parallel to the endpoint's own, that
evaluation indexes directly instead of hashing a three-string policy ID per
policy per flow. A PolicySlot indirection sits between the slice and the
compiled policy so that recompiling a policy publishes through the slot and
leaves compiled endpoints untouched; without it one update to an
all-endpoints policy would rebuild every endpoint on the node. Endpoints are
keyed by the identity of the endpoint object, which is what evaluation has to
hand; a stale copy misses and falls back to the by-ID lookup.

The remaining per-evaluation allocations are removed as well: the log level
is tested once per Evaluate rather than per policy (boxing an int above 255
allocates); the tier default RuleID is precomputed per tier and direction and
the 'no active profiles' RuleIDs built once; actionFromString and
ruleActionFromStr compare with EqualFold instead of building a map and
lowercasing; the requestCache is recycled through a sync.Pool (it escapes
because the compiled matchers take it through a func value); Evaluate appends
the trace to a caller-supplied slice, which the felix collector reuses across
flows; and the matched rule's trace entry is memoized on the compiled rule on
first use (building one per rule eagerly would cost megabytes for entries
that are almost never reached), published atomically since evaluations read
it concurrently under the store's read lock.

Squash of the second, third and sixth to eleventh commits of projectcalico#13267
(c855bb3, e6b5b7e, e671083, c3fb6ca, 16b8eb8,
26d50b8, 64970c9, 411f5f4), rebased onto master after projectcalico#13408,
projectcalico#13410 and projectcalico#13416.
… staged scope

The compiled matchers were reconciled with projectcalico#13408's cheapest-first criterion
order and projectcalico#13416's staged-policy scope while rebasing projectcalico#13267. Pin both with
tests that fail if either drifts again:

- TestCompiledPolicyEquivalence gains an ICMP flow (port 0, so the IP+port
  keys carry "icmp:0"), an IPv6 flow through the NET set's v6 prefix, named
  port sets keyed on the destination leg's "<IP>,<protocol>:<port>" (projectcalico#13174),
  port ranges combined with named sets, wide and multi-range negations, a
  protocol combined with ports and with HTTP criteria, and a rule that uses
  every criterion class at once.
- TestCheckTiersPolicyScope, TestCheckStoreReportsWhichPolicyIsMissing,
  TestEvaluateRecordsStagedPolicyInPendingTraceOnly and
  TestMalformedHTTPPathOnlyFailsRulesThatReachIt run both engines and assert
  they agree, so the scope handling, the fail-closed missing policy and the
  protocol-before-HTTP ordering are checked on the compiled path too.
- BenchmarkEvaluateEgressAllowListCompiled measures the egress allow-list
  fixture from projectcalico#13408 with compiled policies, next to the interpreted one.
@dimitri-nicolo dimitri-nicolo added release-note-required Change has user-facing impact (no matter how small) docs-not-required Docs not required for this change labels Sep 12, 2026
@marvin-tigera marvin-tigera added this to the Calico v3.34.0 milestone Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs-not-required Docs not required for this change release-note-required Change has user-facing impact (no matter how small)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants