Where Cost Hides
In Part 1 the recurring theme was frequency: operations running faster than their data changes. Execution Calculations are the same story told inside the Gameplay Ability System's damage pipeline — except the redundant work is hidden behind one of the most-used virtual functions in the framework: UGameplayEffectExecutionCalculation::Execute_Implementation.
GAS splits magnitude math into two tools. Modifier Magnitude Calculations (MMCs) compute a single scalar. Execution Calculations (exec calcs) are the heavyweight path: they capture multiple attributes from both the source and the target, run arbitrary C++, and emit one or more output modifiers. Every gameplay effect that uses an exec calc runs Execute_Implementation once per application. A single melee hit, one tick of a damage-over-time, one pellet of a multi-shot — each is a separate application, and each runs the full exec.
That's fine at low density. The problem appears when many effects land in the same frame, all reading the same source attributes — which, within a single frame, cannot have changed.
Project: a single-player-with-co-op horde ARPG on UE5.5. Profiled with Unreal Insights CPU trace, game thread isolated. Stress scenario: a boss pull with 18 active enemies, overlapping AoE damage-over-time effects, and a 4-player party. Numbers are averages over a 60-second window, not single-frame spikes.
01 — Capture Redundancy
The Pattern
The project used one UDamageExecutionCalculation for all damage. It captured six attributes: AttackPower, CritChance, and CritMultiplier from the source, and Armor, Resistance, and DamageReduction from the target. Standard, reasonable design. Every damage instance ran the full capture set.
Under the boss pull, Unreal Insights showed 34 exec calc invocations in a single frame — DoT ticks, AoE landings, and a multi-hit cleave all resolving together. The three source attributes are identical across every one of those 34 executions in that frame: it's the same player, the same AbilitySystemComponent, and aggregators do not mutate mid-frame. Yet each execution re-captured them from scratch.
void UDamageExecutionCalculation::Execute_Implementation(
const FGameplayEffectCustomExecutionParameters& Exec,
FGameplayEffectCustomExecutionOutput& Out) const
{
const FGameplayEffectSpec& Spec = Exec.GetOwningSpec();
FAggregatorEvaluateParameters EvalParams;
EvalParams.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
EvalParams.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();
float Attack = 0.f, Crit = 0.f, CritMult = 1.f;
// Each call walks the full source aggregator — every execution, every frame
GetCapturedMagnitude(AttackPowerDef, Exec, EvalParams, Attack);
GetCapturedMagnitude(CritChanceDef, Exec, EvalParams, Crit);
GetCapturedMagnitude(CritMultDef, Exec, EvalParams, CritMult);
float Armor = 0.f, Resist = 0.f, Reduction = 0.f;
GetCapturedMagnitude(ArmorDef, Exec, EvalParams, Armor);
GetCapturedMagnitude(ResistDef, Exec, EvalParams, Resist);
GetCapturedMagnitude(ReductionDef, Exec, EvalParams, Reduction);
const float Dmg = ComputeDamage(Attack, Crit, CritMult, Armor, Resist, Reduction);
Out.AddOutputModifier({ HealthProperty, EGameplayModOp::Additive, -Dmg });
}
Execute_Implementation blocks within one frame, each fanning out into six GetCapturedMagnitude calls against identical source aggregators.02 — Aggregator Re-Walk
The Pattern
A captured-magnitude read is not a float load. AttemptCalculateCapturedAttributeMagnitude resolves the attribute's FAggregator, then walks every active modifier applied to it — additive, multiplicative, and override mods across all currently-applied gameplay effects — honoring channel order and clamping at the end. For a buffed player mid-fight, an offensive attribute commonly carries 8–12 active mods (auras, consumables, set bonuses, temporary rage stacks).
That walk costs roughly 0.01–0.02ms per attribute. Six captures across 34 executions is 204 aggregator walks in a single frame — and three of every six are recomputing source values that are provably constant for the frame. Measured cost of the capture stage alone during the boss pull: 2.6ms per frame, landing squarely inside the combat moments where frame pacing matters most.
The aggregator walk is correct and necessary — once. The waste is recomputing a value that cannot change between two executions in the same frame. An attribute's magnitude is stable for the duration of a frame unless something in that frame writes to it. Source offensive stats, during a damage batch, are not written to.
The Snapshot Cache
The fix is a frame-scoped snapshot: a small cache keyed by the backing attribute, the source ASC, and the current GFrameCounter. The first execution in a frame pays the aggregator walk and stores the result. Every subsequent execution in the same frame reads the cached float. A helper, FetchCachedMagnitude, wraps the native capture so call sites barely change.
// Cache key — attribute + owning ASC + frame
struct FAttrSnapshotKey
{
FGameplayAttribute Attribute;
const UAbilitySystemComponent* Source = nullptr;
bool operator==(const FAttrSnapshotKey& O) const {
return Attribute == O.Attribute && Source == O.Source;
}
};
FORCEINLINE uint32 GetTypeHash(const FAttrSnapshotKey& K) {
return HashCombine(GetTypeHash(K.Attribute), GetTypeHash(K.Source));
}
// Frame-scoped store — cleared lazily when the frame number moves on
struct FAttrSnapshot
{
TMap<FAttrSnapshotKey, float> Values;
uint64 Frame = 0;
};
// Helper — capture once per (attribute, source) per frame
float FetchCachedMagnitude(
FAttrSnapshot& Snap,
const FGameplayEffectAttributeCaptureDefinition& Def,
const UAbilitySystemComponent* SourceASC,
const FGameplayEffectCustomExecutionParameters& Exec,
const FAggregatorEvaluateParameters& EvalParams)
{
if (Snap.Frame != GFrameCounter) { // new frame — drop stale snapshot
Snap.Values.Reset();
Snap.Frame = GFrameCounter;
}
const FAttrSnapshotKey Key{ Def.AttributeToCapture, SourceASC };
if (float* Hit = Snap.Values.Find(Key)) {
return *Hit; // cached — no aggregator walk
}
float Mag = 0.f;
Exec.AttemptCalculateCapturedAttributeMagnitude(Def, EvalParams, Mag);
Snap.Values.Add(Key, Mag);
return Mag;
}
Only the source captures route through the cache. Each Execute_Implementation grabs a per-source snapshot (held on a lightweight subsystem or the source ASC), and the three offensive reads become a single aggregator walk per attribute for the whole frame. Across 34 executions, source walks dropped from 102 to 3.
Target captures are not safe to cache blindly. If an earlier execution in the same frame writes to the target — armor shred landing before the damage that reads armor — a cached target value would be stale and wrong. Cache only attributes that nothing in the same batch mutates. Here that means source offensive stats; target defensive stats stay live. The cache key must encode every input that can legitimately change the answer.
Before & After
The number of executions did not change — every gameplay effect still runs its exec calc, output is bit-identical, and the GE data model was untouched. Only the capture stage got cheaper, by removing walks that produced a value already known for the frame.
| Metric | Before | After | Saving |
|---|---|---|---|
| Source aggregator walks / frame | 102 | 3 | −97% |
| Total capture-stage cost | 2.60ms / frame | 0.30ms / frame | 2.30ms |
| Exec output (damage values) | Correct | Bit-identical | No change |
| Frame budget recovered (60hz) | — | ~14% of 16.67ms | ~2.3ms |
Conclusion
Execution Calculations are not slow. Capturing the same player's AttackPower thirty-four times in a single frame is. The exec calc is the correct place to do damage math; it is the wrong place to re-derive a value that was already correct one microsecond earlier in the same frame.
This is the same lesson as Part 1, viewed through a different subsystem: match the rate of an operation to the rate its inputs actually change. A frame-scoped snapshot is the GAS-shaped version of the cached-state helper — it encapsulates the caching decision in one place, leaves every call site readable, and enforces the constraint without asking the ability or effect authors to reason about it.
Capture once per frame, not once per effect. The damage is the same; the cost is not.
— Reignance Studios · Technical Audit SeriesIf your combat profile shows the GameplayEffect pipeline climbing as encounter density rises — DoT-heavy builds, AoE stacking, multi-hit abilities — the capture stage is the first place to look. A focused audit isolates it quickly.
Part 3 moves from the game thread to the network: replacing the default replication driver with a spatialized Replication Graph to keep a 100-player dedicated server inside its bandwidth and CPU budget — relevancy nodes, dormancy, and the per-connection always-relevant split.