Technical Audit GAS / Performance JUN 2026 · 10 MIN READ

GAS Execution Calcs:
Taming Attribute
Read Overhead

Part 2 of the series. Execution Calculations are where the Gameplay Ability System does its real damage math — and where the same stable attributes get captured over and over, once per simultaneous effect. This audit traces that overhead and the frame-scoped snapshot cache that erases it without touching the gameplay effect data model.

RS
Reignance Studios // UE5 SYSTEMS & PERFORMANCE AUDIT

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.

// Audit Context

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.

C++ DamageExecutionCalculation.cpp — the redundant path
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 });
}
[ insights_execcalc_fanout_before.png ]
Fig 1. Insights timeline — 34 stacked 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.

Before — Capture Stage Cost (boss pull)
2.6ms
Per frame · 204 aggregator walks
Executions / Frame (peak)
34
DoT + AoE + multi-hit overlap
Redundant Source Walks
~50%
Source stats constant within frame
// Key Insight

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.

C++ GASAttributeSnapshot.h / .cpp
// 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.

// Important Constraint

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.

[ insights_execcalc_snapshot_after.png ]
Fig 2. Same boss pull, capture stage before (left) and after (right) the snapshot cache — the dense fan-out of source aggregator walks collapses to a single walk per attribute per frame.

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
Before — Capture Stage
2.6ms
Per frame · boss pull peak
After — Snapshot Cache
0.3ms
Per frame · same scenario
Time to Implement
1–2 days
Including correctness verification

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 Series

If 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.

// Next in this Series

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.