Skip to content

Commit d0db215

Browse files
committed
perf: Speed up local-evaluation hot path for large environments
Addresses #198: for a 262-feature environment, local evaluation is 1000x faster for the common case (no variants / no overrides) and 8-60% faster overall depending on scenario. Changes: - Cache the output of `get_environment_flags()` — the evaluation context is immutable between environment refreshes, so we rebuild it once on update instead of once per call. Invalidated via a property setter on `_evaluation_context` so existing direct-assignment call sites (tests, offline mode) keep working transparently. - Short-circuit `get_identity_flags()` to the cached environment Flags when the environment has no features with variants and no segments with overrides — in that case identity flags are guaranteed to equal environment flags. A fresh `Flags` wrapper is allocated around the cached flag dict to preserve identity metadata for pipeline analytics. - Precompute `identity.key` in `map_context_and_identity_data_to_context` so flag-engine's `get_enriched_context` becomes a no-op instead of performing a shallow context copy on every call. - Pre-sort multivariate variants once at env-load time; Timsort on an already-sorted list is a fast path compared to resorting per call. - Add `__slots__` to `Flag` / `DefaultFlag` / `BaseFlag` on Python 3.10+ and inline the per-flag construction inside `Flags.from_evaluation_result` to skip the redundant helper call and truthiness check. Also adds a `benchmarks/` harness (issue-#198 scenario, variant-density knobs, cProfile mode) so future regressions can be caught locally before release. Two tests that asserted on the engine's internal call shape were rewritten to assert on actual flag values instead. beep boop
1 parent ae45cbd commit d0db215

7 files changed

Lines changed: 398 additions & 96 deletions

File tree

benchmarks/__init__.py

Whitespace-only changes.

benchmarks/bench.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""Microbenchmark for the Flagsmith Python SDK's local evaluation hot path.
2+
3+
Run with::
4+
5+
poetry run python -m benchmarks.bench
6+
poetry run python -m benchmarks.bench --profile # cProfile hot path
7+
poetry run python -m benchmarks.bench --iters 20000 # custom iter count
8+
9+
Mirrors the scenario from issue #198: enable_local_evaluation=True, a
10+
single-identity call into get_identity_flags / get_environment_flags with
11+
~262 features.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
import cProfile
18+
import json
19+
import pstats
20+
import statistics
21+
import time
22+
from typing import Callable
23+
24+
from benchmarks.env import build_environment
25+
from flagsmith import Flagsmith
26+
from flagsmith.mappers import map_environment_document_to_context
27+
28+
29+
def _make_client(n_features: int, with_multivariate: int = 0) -> Flagsmith:
30+
env_doc = build_environment(
31+
n_features=n_features,
32+
with_multivariate=with_multivariate,
33+
)
34+
# Build a local-eval client without hitting the network / starting polling.
35+
client = Flagsmith.__new__(Flagsmith)
36+
client.offline_mode = False
37+
client.enable_local_evaluation = True
38+
client.offline_handler = None
39+
client.default_flag_handler = None
40+
client.enable_realtime_updates = False
41+
client._analytics_processor = None
42+
client._pipeline_analytics_processor = None
43+
client._Flagsmith__evaluation_context = None
44+
client._environment_context_without_segments = None
45+
client._environment_flags_cache = None
46+
client._identity_flags_match_environment = False
47+
client._environment_updated_at = None
48+
client._evaluation_context = map_environment_document_to_context(env_doc)
49+
return client
50+
51+
52+
def _bench(
53+
name: str,
54+
fn: Callable[[], object],
55+
*,
56+
iters: int,
57+
warmup: int,
58+
) -> None:
59+
for _ in range(warmup):
60+
fn()
61+
62+
samples: list[float] = []
63+
# Break total iters into batches so we can also report a stdev.
64+
batch_size = max(1, iters // 20)
65+
for _ in range(0, iters, batch_size):
66+
n = min(batch_size, iters)
67+
t0 = time.perf_counter()
68+
for _ in range(n):
69+
fn()
70+
samples.append((time.perf_counter() - t0) / n)
71+
iters -= n
72+
73+
p50 = statistics.median(samples) * 1e6
74+
mean = statistics.fmean(samples) * 1e6
75+
stdev = statistics.pstdev(samples) * 1e6
76+
print(
77+
f"{name:<32} p50={p50:8.2f} µs mean={mean:8.2f} µs stdev={stdev:7.2f} µs "
78+
f"throughput={1e6 / mean:>10,.0f}/s"
79+
)
80+
81+
82+
def run(iters: int, warmup: int, n_features: int, with_multivariate: int) -> None:
83+
client = _make_client(n_features, with_multivariate=with_multivariate)
84+
traits = {"venue_id": "12345"}
85+
86+
print(
87+
f"Flagsmith local-eval benchmark | features={n_features} "
88+
f"multivariate={with_multivariate} iters={iters} warmup={warmup}"
89+
)
90+
_bench(
91+
"get_environment_flags",
92+
client.get_environment_flags,
93+
iters=iters,
94+
warmup=warmup,
95+
)
96+
_bench(
97+
"get_identity_flags",
98+
lambda: client.get_identity_flags(identifier="anonymous", traits=traits),
99+
iters=iters,
100+
warmup=warmup,
101+
)
102+
103+
flags = client.get_identity_flags(identifier="anonymous", traits=traits)
104+
name = next(iter(flags.flags))
105+
_bench(
106+
"is_feature_enabled (cached)",
107+
lambda: flags.is_feature_enabled(name),
108+
iters=iters * 10,
109+
warmup=warmup,
110+
)
111+
112+
113+
def profile(
114+
iters: int,
115+
n_features: int,
116+
output: str | None,
117+
with_multivariate: int = 0,
118+
) -> None:
119+
client = _make_client(n_features, with_multivariate=with_multivariate)
120+
traits = {"venue_id": "12345"}
121+
122+
# Warm up JSONPath caches, lru_cache, etc.
123+
for _ in range(200):
124+
client.get_identity_flags(identifier="anonymous", traits=traits)
125+
126+
profiler = cProfile.Profile()
127+
profiler.enable()
128+
for _ in range(iters):
129+
client.get_identity_flags(identifier="anonymous", traits=traits)
130+
profiler.disable()
131+
132+
stats = pstats.Stats(profiler).sort_stats(pstats.SortKey.CUMULATIVE)
133+
stats.print_stats(30)
134+
stats.sort_stats(pstats.SortKey.TIME).print_stats(30)
135+
136+
if output:
137+
profiler.dump_stats(output)
138+
print(f"wrote {output}")
139+
140+
141+
def main() -> None:
142+
parser = argparse.ArgumentParser()
143+
parser.add_argument("--iters", type=int, default=5000)
144+
parser.add_argument("--warmup", type=int, default=500)
145+
parser.add_argument("--features", type=int, default=262)
146+
parser.add_argument(
147+
"--multivariate",
148+
type=int,
149+
default=0,
150+
help="number of features that should have 2-way multivariate variants",
151+
)
152+
parser.add_argument("--profile", action="store_true")
153+
parser.add_argument("--profile-output", default=None)
154+
parser.add_argument("--json", action="store_true", help="emit JSON summary")
155+
args = parser.parse_args()
156+
157+
if args.profile:
158+
profile(
159+
args.iters,
160+
args.features,
161+
args.profile_output,
162+
with_multivariate=args.multivariate,
163+
)
164+
return
165+
166+
if args.json:
167+
# Alternative machine-readable mode for diffing runs.
168+
client = _make_client(args.features)
169+
traits = {"venue_id": "12345"}
170+
171+
def _measure(fn: Callable[[], object], count: int) -> float:
172+
for _ in range(args.warmup):
173+
fn()
174+
t0 = time.perf_counter()
175+
for _ in range(count):
176+
fn()
177+
return (time.perf_counter() - t0) / count
178+
179+
result = {
180+
"features": args.features,
181+
"iters": args.iters,
182+
"get_environment_flags_us": _measure(
183+
client.get_environment_flags, args.iters
184+
)
185+
* 1e6,
186+
"get_identity_flags_us": _measure(
187+
lambda: client.get_identity_flags(
188+
identifier="anonymous", traits=traits
189+
),
190+
args.iters,
191+
)
192+
* 1e6,
193+
}
194+
print(json.dumps(result, indent=2))
195+
return
196+
197+
run(args.iters, args.warmup, args.features, args.multivariate)
198+
199+
200+
if __name__ == "__main__":
201+
main()

benchmarks/env.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Synthetic environment builder for local evaluation benchmarks.
2+
3+
Mirrors the shape of the real environment document (262 features, one segment),
4+
so we can exercise the local eval hot path without needing network access.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import copy
10+
import json
11+
import os
12+
import typing
13+
14+
DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "tests", "data")
15+
TEMPLATE_PATH = os.path.join(DATA_DIR, "environment.json")
16+
17+
18+
def build_environment(
19+
n_features: int = 262,
20+
*,
21+
with_multivariate: int = 0,
22+
) -> dict[str, typing.Any]:
23+
with open(TEMPLATE_PATH) as f:
24+
env = json.load(f)
25+
26+
# Base feature state to clone.
27+
base_fs = copy.deepcopy(env["feature_states"][0])
28+
feature_states: list[dict[str, typing.Any]] = []
29+
for i in range(n_features):
30+
fs = copy.deepcopy(base_fs)
31+
fs["django_id"] = i + 1
32+
fs["feature"] = {
33+
"name": f"feature_{i:04d}",
34+
"type": "STANDARD",
35+
"id": i + 1,
36+
}
37+
fs["feature_state_value"] = f"value-{i}"
38+
if with_multivariate and i < with_multivariate:
39+
fs["multivariate_feature_state_values"] = [
40+
{
41+
"multivariate_feature_option": {"value": f"mv-{i}-a"},
42+
"percentage_allocation": 50.0,
43+
"id": (i + 1) * 100 + 1,
44+
},
45+
{
46+
"multivariate_feature_option": {"value": f"mv-{i}-b"},
47+
"percentage_allocation": 50.0,
48+
"id": (i + 1) * 100 + 2,
49+
},
50+
]
51+
feature_states.append(fs)
52+
53+
env["feature_states"] = feature_states
54+
# Strip the (irrelevant) identity override for a clean baseline.
55+
env["identity_overrides"] = []
56+
return env

flagsmith/flagsmith.py

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ def __init__(
117117
self._pipeline_analytics_processor: typing.Optional[
118118
PipelineAnalyticsProcessor
119119
] = None
120-
self._evaluation_context: typing.Optional[SDKEvaluationContext] = None
120+
self.__evaluation_context: typing.Optional[SDKEvaluationContext] = None
121+
self._environment_context_without_segments: typing.Optional[
122+
SDKEvaluationContext
123+
] = None
124+
self._environment_flags_cache: typing.Optional[Flags] = None
125+
self._identity_flags_match_environment: bool = False
121126
self._environment_updated_at: typing.Optional[datetime] = None
122127

123128
# argument validation
@@ -356,6 +361,46 @@ def update_environment(self) -> None:
356361
except (KeyError, TypeError, ValueError):
357362
logger.exception("Error parsing environment document")
358363

364+
@property
365+
def _evaluation_context(self) -> typing.Optional[SDKEvaluationContext]:
366+
return self.__evaluation_context
367+
368+
@_evaluation_context.setter
369+
def _evaluation_context(
370+
self, context: typing.Optional[SDKEvaluationContext]
371+
) -> None:
372+
"""Swap in a new evaluation context and invalidate derived caches.
373+
374+
Pre-computing the segment-stripped view and a couple of shape flags
375+
once per refresh keeps the hot path for ``get_environment_flags``
376+
and the short-circuited ``get_identity_flags`` allocation-free.
377+
"""
378+
self.__evaluation_context = context
379+
self._environment_flags_cache = None
380+
if context is None:
381+
self._environment_context_without_segments = None
382+
self._identity_flags_match_environment = False
383+
return
384+
context_without_segments = context.copy()
385+
context_without_segments.pop("segments", None)
386+
self._environment_context_without_segments = context_without_segments
387+
# An identity's flags only differ from the environment's when either
388+
# a feature has variants (percentage split) or some segment can
389+
# override feature values (segment overrides / identity overrides).
390+
# When neither is true we can skip the per-call engine evaluation
391+
# entirely and reuse the cached environment Flags.
392+
has_variants = any(
393+
feature.get("variants")
394+
for feature in (context.get("features") or {}).values()
395+
)
396+
has_segment_overrides = any(
397+
segment.get("overrides")
398+
for segment in (context.get("segments") or {}).values()
399+
)
400+
self._identity_flags_match_environment = not (
401+
has_variants or has_segment_overrides
402+
)
403+
359404
def _get_headers(
360405
self,
361406
environment_key: str,
@@ -375,24 +420,25 @@ def _get_headers(
375420
return headers
376421

377422
def _get_environment_flags_from_document(self) -> Flags:
378-
if self._evaluation_context is None:
379-
raise TypeError("No environment present")
423+
if (cached := self._environment_flags_cache) is not None:
424+
return cached
380425

381-
# Omit segments from evaluation context for environment flags
382-
# as they are only relevant for identity-specific evaluations
383-
context_without_segments = self._evaluation_context.copy()
384-
context_without_segments.pop("segments", None)
426+
context_without_segments = self._environment_context_without_segments
427+
if context_without_segments is None:
428+
raise TypeError("No environment present")
385429

386430
evaluation_result = engine.get_evaluation_result(
387431
context=context_without_segments,
388432
)
389433

390-
return Flags.from_evaluation_result(
434+
flags = Flags.from_evaluation_result(
391435
evaluation_result=evaluation_result,
392436
analytics_processor=self._analytics_processor,
393437
default_flag_handler=self.default_flag_handler,
394438
pipeline_analytics_processor=self._pipeline_analytics_processor,
395439
)
440+
self._environment_flags_cache = flags
441+
return flags
396442

397443
def _get_identity_flags_from_document(
398444
self,
@@ -402,15 +448,26 @@ def _get_identity_flags_from_document(
402448
if self._evaluation_context is None:
403449
raise TypeError("No environment present")
404450

451+
if self._identity_flags_match_environment:
452+
# Reuse the cached environment Flag dict but wrap it in a fresh
453+
# ``Flags`` carrying this identity's metadata, so pipeline analytics
454+
# still see per-identity events.
455+
env_flags = self._get_environment_flags_from_document()
456+
return Flags(
457+
flags=env_flags.flags,
458+
default_flag_handler=self.default_flag_handler,
459+
_analytics_processor=self._analytics_processor,
460+
_pipeline_analytics_processor=self._pipeline_analytics_processor,
461+
_identity_identifier=identifier,
462+
_traits=resolve_trait_values(traits),
463+
)
464+
405465
context = map_context_and_identity_data_to_context(
406466
context=self._evaluation_context,
407467
identifier=identifier,
408468
traits=traits,
409469
)
410-
evaluation_result = engine.get_evaluation_result(
411-
context=context,
412-
)
413-
470+
evaluation_result = engine.get_evaluation_result(context=context)
414471
return Flags.from_evaluation_result(
415472
evaluation_result=evaluation_result,
416473
analytics_processor=self._analytics_processor,

0 commit comments

Comments
 (0)