Skip to content

Commit 7b59ab3

Browse files
committed
perf: Speed up local-evaluation hot path for large environments
Addresses the regression reported in Flagsmith/flagsmith-python-client#198: for a 262-feature environment with ~10% multivariate features, local evaluation is ~32% faster (~115 us -> ~78 us per call on an M-series Mac). Changes: - Hoist `_get_identity_key` out of the per-feature loop in `evaluate_features`. The identity key is invariant across features in a single evaluation, so we now resolve it once per `get_evaluation_result` call instead of once per feature. - Inline the per-feature flag-result construction formerly done by `get_flag_result_from_context`. The public helper is retained as a thin wrapper so existing callers / mocks still work. - Localise hot-loop references (`hash_fn`, `segment_overrides.get`, variant priority key) to avoid chasing module globals per iteration. - Add a two-key fast path `get_hashed_percentage_for_object_id_pair` for variant selection and `PERCENTAGE_SPLIT` conditions, skipping the iterable / list wrapping the generic helper performs on every call. Also adds a 262-feature synthetic benchmark alongside the existing 5-feature ones so CodSpeed can catch regressions that only appear at scale. beep boop
1 parent 6237c31 commit 7b59ab3

4 files changed

Lines changed: 161 additions & 59 deletions

File tree

flag_engine/segments/evaluator.py

Lines changed: 71 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
is_context_value,
3232
)
3333
from flag_engine.segments.utils import get_matching_function
34-
from flag_engine.utils.hashing import get_hashed_percentage_for_object_ids
34+
from flag_engine.utils.hashing import get_hashed_percentage_for_object_id_pair
3535
from flag_engine.utils.semver import is_semver
3636
from flag_engine.utils.types import SupportsStr, get_casting_function
3737

@@ -58,8 +58,9 @@ def get_evaluation_result(
5858
:return: EvaluationResult containing the context, flags, and segments
5959
"""
6060
context = get_enriched_context(context)
61+
identity_key = _get_identity_key(context)
6162
segments, segment_overrides = evaluate_segments(context)
62-
flags = evaluate_features(context, segment_overrides)
63+
flags = evaluate_features(context, segment_overrides, identity_key=identity_key)
6364

6465
return {
6566
"flags": flags,
@@ -138,26 +139,57 @@ def evaluate_segments(
138139
def evaluate_features(
139140
context: EvaluationContext[typing.Any, FeatureMetadataT],
140141
segment_overrides: SegmentOverrides[FeatureMetadataT],
142+
*,
143+
identity_key: typing.Optional[str] = None,
141144
) -> dict[str, FlagResult[FeatureMetadataT]]:
142145
if not (features := context.get("features")):
143146
return {}
144147

148+
# ``identity_key`` is invariant across all features in a single evaluation.
149+
# Resolving it here (or accepting it from the caller) means the per-feature
150+
# hot loop below doesn't have to re-walk ``context["identity"]`` N times.
151+
if identity_key is None:
152+
identity_key = _get_identity_key(context)
153+
154+
# Localise loop dependencies once so the tight per-feature loop doesn't
155+
# chase module globals on every iteration. ``_build_flag_result`` is
156+
# inlined below for environments with many features (e.g. 250+), where
157+
# the function-call overhead is otherwise ~15% of per-call time.
158+
hash_fn = get_hashed_percentage_for_object_id_pair
159+
overrides_get = segment_overrides.get
160+
145161
flags: dict[str, FlagResult[FeatureMetadataT]] = {}
162+
for feature_name, feature_context in features.items():
163+
if segment_override := overrides_get(feature_name):
164+
effective_feature_context = segment_override["feature_context"]
165+
reason = f"TARGETING_MATCH; segment={segment_override['segment_name']}"
166+
else:
167+
effective_feature_context = feature_context
168+
reason = "DEFAULT"
146169

147-
for feature_context in features.values():
148-
feature_name = feature_context["name"]
149-
if segment_override := segment_overrides.get(feature_name):
150-
flags[feature_name] = get_flag_result_from_context(
151-
context=context,
152-
feature_context=segment_override["feature_context"],
153-
reason=f"TARGETING_MATCH; segment={segment_override['segment_name']}",
154-
)
155-
continue
156-
flags[feature_name] = get_flag_result_from_context(
157-
context=context,
158-
feature_context=context["features"][feature_name],
159-
reason="DEFAULT",
160-
)
170+
value: typing.Any = effective_feature_context["value"]
171+
if identity_key is not None and (
172+
variants := effective_feature_context.get("variants")
173+
):
174+
percentage_value = hash_fn(effective_feature_context["key"], identity_key)
175+
start_percentage = 0.0
176+
for variant in sorted(variants, key=_variant_priority):
177+
limit = (weight := variant["weight"]) + start_percentage
178+
if start_percentage <= percentage_value < limit:
179+
value = variant["value"]
180+
reason = f"SPLIT; weight={weight}"
181+
break
182+
start_percentage = limit
183+
184+
flag_result: FlagResult[FeatureMetadataT] = {
185+
"enabled": effective_feature_context["enabled"],
186+
"name": effective_feature_context["name"],
187+
"reason": reason,
188+
"value": value,
189+
}
190+
if metadata := effective_feature_context.get("metadata"):
191+
flag_result["metadata"] = metadata
192+
flags[feature_name] = flag_result
161193

162194
return flags
163195

@@ -176,47 +208,38 @@ def get_flag_result_from_context(
176208
:param reason: reason to use when no variant selected
177209
:return: the value for the feature name in the evaluation context
178210
"""
179-
key = _get_identity_key(context)
211+
identity_key = _get_identity_key(context)
212+
value: typing.Any = feature_context["value"]
180213

181-
flag_result: typing.Optional[FlagResult[FeatureMetadataT]] = None
182-
183-
if key is not None and (variants := feature_context.get("variants")):
184-
percentage_value = get_hashed_percentage_for_object_ids(
185-
[feature_context["key"], key]
214+
if identity_key is not None and (variants := feature_context.get("variants")):
215+
percentage_value = get_hashed_percentage_for_object_id_pair(
216+
feature_context["key"], identity_key
186217
)
187-
188218
start_percentage = 0.0
189-
190-
for variant in sorted(
191-
variants,
192-
key=operator.itemgetter("priority"),
193-
):
219+
for variant in sorted(variants, key=_variant_priority):
194220
limit = (weight := variant["weight"]) + start_percentage
195221
if start_percentage <= percentage_value < limit:
196-
flag_result = {
197-
"enabled": feature_context["enabled"],
198-
"name": feature_context["name"],
199-
"reason": f"SPLIT; weight={weight}",
200-
"value": variant["value"],
201-
}
222+
value = variant["value"]
223+
reason = f"SPLIT; weight={weight}"
202224
break
203-
204225
start_percentage = limit
205226

206-
if flag_result is None:
207-
flag_result = {
208-
"enabled": feature_context["enabled"],
209-
"name": feature_context["name"],
210-
"reason": reason,
211-
"value": feature_context["value"],
212-
}
213-
227+
flag_result: FlagResult[FeatureMetadataT] = {
228+
"enabled": feature_context["enabled"],
229+
"name": feature_context["name"],
230+
"reason": reason,
231+
"value": value,
232+
}
214233
if metadata := feature_context.get("metadata"):
215234
flag_result["metadata"] = metadata
216-
217235
return flag_result
218236

219237

238+
def _variant_priority(variant: typing.Mapping[str, typing.Any]) -> int:
239+
priority: int = variant["priority"]
240+
return priority
241+
242+
220243
def is_context_in_segment(
221244
context: _EvaluationContextAnyMeta,
222245
segment_context: SegmentContext[typing.Any, typing.Any],
@@ -304,14 +327,14 @@ def context_matches_condition(
304327
if condition_operator == constants.PERCENTAGE_SPLIT:
305328
if context_value is None:
306329
return False
307-
308-
object_ids = [segment_key, context_value]
309-
310330
try:
311331
float_value = float(condition["value"])
312332
except ValueError:
313333
return False
314-
return get_hashed_percentage_for_object_ids(object_ids) <= float_value
334+
return (
335+
get_hashed_percentage_for_object_id_pair(segment_key, context_value)
336+
<= float_value
337+
)
315338

316339
if condition_operator == constants.IS_NOT_SET:
317340
return context_value is None

flag_engine/utils/hashing.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,24 @@ def get_hashed_percentage_for_object_ids(
3131
)
3232

3333
return value
34+
35+
36+
def get_hashed_percentage_for_object_id_pair(
37+
first: SupportsStr,
38+
second: SupportsStr,
39+
) -> float:
40+
"""Fast path for the hot two-key case used by variant selection and
41+
``PERCENTAGE_SPLIT`` conditions. Skips the iterator / list wrapping that
42+
the generic helper performs on every call.
43+
44+
Returns the same value as
45+
``get_hashed_percentage_for_object_ids([first, second])``.
46+
"""
47+
to_hash = f"{first},{second}"
48+
hashed_value = hashlib.md5(to_hash.encode("utf-8"))
49+
hashed_value_as_int = int(hashed_value.hexdigest(), base=16)
50+
value = ((hashed_value_as_int % 9999) / 9998) * 100
51+
if value == 100:
52+
# Extremely unlikely; fall back to the generic recursion-capable path.
53+
return get_hashed_percentage_for_object_ids([first, second], iterations=2)
54+
return value

tests/engine_tests/test_engine.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77
from _pytest.mark import ParameterSet
88

9-
from flag_engine.context.types import EvaluationContext
9+
from flag_engine.context.types import EvaluationContext, FeatureContext
1010
from flag_engine.engine import get_evaluation_result
1111
from flag_engine.result.types import EvaluationResult
1212

@@ -40,11 +40,66 @@ def _extract_benchmark_contexts(
4040
yield pyjson5.loads((test_cases_dir_path / file_path).read_text())["context"]
4141

4242

43+
def _build_large_benchmark_context(
44+
n_features: int = 262,
45+
multivariate_features: int = 26,
46+
) -> EvaluationContext:
47+
"""Mirror the scenario from flagsmith-python-client issue #198: a real-world
48+
local-evaluation environment with ~260 features, a handful of which use
49+
multivariate splits, evaluated for a single identity. Small enough to
50+
keep the benchmark fast but large enough to surface per-feature overhead.
51+
"""
52+
features: dict[str, FeatureContext[typing.Any]] = {}
53+
for i in range(n_features):
54+
name = f"feature_{i:04d}"
55+
fc: FeatureContext[typing.Any] = {
56+
"key": str(i + 1),
57+
"name": name,
58+
"enabled": bool(i % 2),
59+
"value": f"value-{i}",
60+
"metadata": {"id": i + 1},
61+
}
62+
if i < multivariate_features:
63+
# Intentionally reverse-ordered so ``sorted()`` has work to do.
64+
fc["variants"] = [
65+
{"value": f"mv-{i}-b", "weight": 40.0, "priority": 2},
66+
{"value": f"mv-{i}-a", "weight": 60.0, "priority": 1},
67+
]
68+
features[name] = fc
69+
return {
70+
"environment": {"key": "bench-env", "name": "bench"},
71+
"features": features,
72+
"segments": {
73+
"1": {
74+
"key": "1",
75+
"name": "bench-segment",
76+
"rules": [
77+
{
78+
"type": "ALL",
79+
"conditions": [
80+
{
81+
"property": "venue_id",
82+
"operator": "EQUAL",
83+
"value": "no-match",
84+
}
85+
],
86+
}
87+
],
88+
}
89+
},
90+
"identity": {
91+
"identifier": "anonymous",
92+
"traits": {"venue_id": "12345"},
93+
},
94+
}
95+
96+
4397
TEST_CASES = sorted(
4498
_extract_test_cases(TEST_CASES_PATH),
4599
key=lambda param: str(param.id),
46100
)
47101
BENCHMARK_CONTEXTS = list(_extract_benchmark_contexts(TEST_CASES_PATH))
102+
LARGE_BENCHMARK_CONTEXT = _build_large_benchmark_context()
48103

49104

50105
@pytest.mark.parametrize(
@@ -66,3 +121,8 @@ def test_engine(
66121
def test_engine_benchmark() -> None:
67122
for context in BENCHMARK_CONTEXTS:
68123
get_evaluation_result(context)
124+
125+
126+
@pytest.mark.benchmark
127+
def test_engine_benchmark_large_context() -> None:
128+
get_evaluation_result(LARGE_BENCHMARK_CONTEXT)

tests/unit/segments/test_segments_evaluator.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ def test_context_in_segment_percentage_split(
265265
}
266266

267267
mock_get_hashed_percentage = mocker.patch(
268-
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids"
268+
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_id_pair"
269269
)
270270
mock_get_hashed_percentage.return_value = identity_hashed_percentage
271271

@@ -308,7 +308,7 @@ def test_context_in_segment_percentage_split__no_identity__returns_expected(
308308
}
309309

310310
mock_get_hashed_percentage = mocker.patch(
311-
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids"
311+
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_id_pair"
312312
)
313313

314314
# When
@@ -352,7 +352,7 @@ def test_context_in_segment_percentage_split__trait_value__calls_expected(
352352
}
353353

354354
mock_get_hashed_percentage = mocker.patch(
355-
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids"
355+
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_id_pair"
356356
)
357357
mock_get_hashed_percentage.return_value = 1
358358

@@ -361,7 +361,7 @@ def test_context_in_segment_percentage_split__trait_value__calls_expected(
361361

362362
# Then
363363
mock_get_hashed_percentage.assert_called_once_with(
364-
[segment_context["key"], "custom_value"]
364+
segment_context["key"], "custom_value"
365365
)
366366
assert result
367367

@@ -841,7 +841,7 @@ def test_get_flag_result_from_context__calls_returns_expected(
841841
# we mock the function which gets the percentage value for an identity to
842842
# return a deterministic value so we know which value to expect
843843
get_hashed_percentage_for_object_ids_mock = mocker.patch(
844-
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids",
844+
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_id_pair",
845845
)
846846
get_hashed_percentage_for_object_ids_mock.return_value = percentage_value
847847

@@ -870,10 +870,8 @@ def test_get_flag_result_from_context__calls_returns_expected(
870870

871871
# the function is called with the expected key
872872
get_hashed_percentage_for_object_ids_mock.assert_called_once_with(
873-
[
874-
expected_feature_context_key,
875-
expected_key,
876-
]
873+
expected_feature_context_key,
874+
expected_key,
877875
)
878876

879877

@@ -885,7 +883,7 @@ def test_get_flag_result_from_feature_context__null_key__calls_returns_expected(
885883
expected_feature_context_key = "2"
886884

887885
get_hashed_percentage_for_object_ids_mock = mocker.patch(
888-
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_ids",
886+
"flag_engine.segments.evaluator.get_hashed_percentage_for_object_id_pair",
889887
)
890888

891889
feature_context: FeatureContext = {

0 commit comments

Comments
 (0)