The soon-to-be-opened "explicit units" PR (built on PSY lk/units-fold-psu and IS lk/units-domain-agnostic-is4) removes the Val{IS.UnitSystem.X}() cost-coefficient dispatch antipattern. This issue documents the resulting type-stability picture: which rungs of the ladder are now in effect, which are cheap follow-ups, and which require deeper architectural change.
The ladder
A call to add_variable_cost_to_objective!(container, T, component, cost_function, formulation) runs IS.convert_cost_coefficient for each cost term. How statically that conversion resolves depends on whether U (the unit-system type parameter on CostCurve{X, U} / FuelCurve{X, U}) reaches the conversion site at each rung.
-
Val{}-on-runtime-enum (removed). Old code wrapped a runtime IS.UnitSystem enum value as a Val{} type tag at the helper call: a value-to-type boundary inside a hot loop, type-unstable by construction. Replaced by direct dispatch on IS.AbstractUnitSystem singleton instances, which the new IS.convert_cost_coefficient consumes via ordinary method dispatch.
-
Per-call-site specialization (automatic). add_variable_cost_to_objective! dispatches on cost_function::IS.CostCurve{X} (UnionAll over U). Julia's JIT specializes the method body per concrete U it observes at runtime; inside each specialization U is statically known and convert_cost_coefficient(.., NaturalUnit(), ..) folds at compile time. The dynamic-dispatch cost is paid once at method entry — once per add_variable_cost_to_objective! call, not once per coefficient conversion. No source change needed to enable this; it follows automatically from putting U on the curve type.
-
where U propagation (cheap, per-method). Any upstream method that wants to preserve concrete U through its body without depending on JIT specialization can write:
function f(..., cost::CostCurve{X, U}) where {X, U}
add_variable_cost_to_objective!(..., cost, ...)
end
The where clause introduces U as a bound type variable in f's signature; U is concrete inside f's specialization and propagates into the inner call. Adopt per-method where profiling suggests value.
-
Small Union on the field (mechanical). Component fields typed as e.g. Union{MarketBidCost, MarketBidTimeSeriesCost, RenewableGenerationCost} (≤4 members) trigger Julia's union-splitting optimization. Each branch is monomorphic on cost flavor, eliminating the entry-point dynamic dispatch. (Within each branch U is still abstract — see (5).) Independent of the units work; revert from the abstract-supertype field that was adopted when time-variant cost types were split off.
-
Union enumerating U too. 3 unit systems × 3 cost flavors = 9 union members. Past the splitting threshold; dead end.
-
Component-level parameterization. ThermalStandard{U} with operation_cost::ThermalGenerationCost{U}. Full static recovery of U across all access paths, no JIT dependency. Largest cascade — propagates U to user-facing types and every site that constructs/holds a ThermalStandard. Justified only if profiling shows the entry-point dispatch is itself a bottleneck.
Current state and recommendations
- (1) is fixed by the units PR.
- (2) follows automatically; no further work.
- (3) and (4) are cheap, independent improvements available now.
- (5) is unreachable.
- (6) is reserved for a profile-driven decision.
The expensive remaining boundary is the entry-point method-table lookup when cost_function arrives via an abstractly-typed field. That lookup happens once per add_variable_cost_to_objective! call. The conversion math itself is now compile-time-foldable inside the specialization, so the units refactor's payoff is real and immediate. If/when the entry dispatch shows up in a profile, (4) is the lowest-cost mitigation.
The soon-to-be-opened "explicit units" PR (built on PSY
lk/units-fold-psuand ISlk/units-domain-agnostic-is4) removes theVal{IS.UnitSystem.X}()cost-coefficient dispatch antipattern. This issue documents the resulting type-stability picture: which rungs of the ladder are now in effect, which are cheap follow-ups, and which require deeper architectural change.The ladder
A call to
add_variable_cost_to_objective!(container, T, component, cost_function, formulation)runsIS.convert_cost_coefficientfor each cost term. How statically that conversion resolves depends on whetherU(the unit-system type parameter onCostCurve{X, U}/FuelCurve{X, U}) reaches the conversion site at each rung.Val{}-on-runtime-enum (removed). Old code wrapped a runtimeIS.UnitSystemenum value as aVal{}type tag at the helper call: a value-to-type boundary inside a hot loop, type-unstable by construction. Replaced by direct dispatch onIS.AbstractUnitSystemsingleton instances, which the newIS.convert_cost_coefficientconsumes via ordinary method dispatch.Per-call-site specialization (automatic).
add_variable_cost_to_objective!dispatches oncost_function::IS.CostCurve{X}(UnionAll overU). Julia's JIT specializes the method body per concreteUit observes at runtime; inside each specializationUis statically known andconvert_cost_coefficient(.., NaturalUnit(), ..)folds at compile time. The dynamic-dispatch cost is paid once at method entry — once peradd_variable_cost_to_objective!call, not once per coefficient conversion. No source change needed to enable this; it follows automatically from puttingUon the curve type.where Upropagation (cheap, per-method). Any upstream method that wants to preserve concreteUthrough its body without depending on JIT specialization can write:The
whereclause introducesUas a bound type variable inf's signature;Uis concrete insidef's specialization and propagates into the inner call. Adopt per-method where profiling suggests value.Small Union on the field (mechanical). Component fields typed as e.g.
Union{MarketBidCost, MarketBidTimeSeriesCost, RenewableGenerationCost}(≤4 members) trigger Julia's union-splitting optimization. Each branch is monomorphic on cost flavor, eliminating the entry-point dynamic dispatch. (Within each branchUis still abstract — see (5).) Independent of the units work; revert from the abstract-supertype field that was adopted when time-variant cost types were split off.Union enumerating
Utoo. 3 unit systems × 3 cost flavors = 9 union members. Past the splitting threshold; dead end.Component-level parameterization.
ThermalStandard{U}withoperation_cost::ThermalGenerationCost{U}. Full static recovery ofUacross all access paths, no JIT dependency. Largest cascade — propagatesUto user-facing types and every site that constructs/holds aThermalStandard. Justified only if profiling shows the entry-point dispatch is itself a bottleneck.Current state and recommendations
The expensive remaining boundary is the entry-point method-table lookup when
cost_functionarrives via an abstractly-typed field. That lookup happens once peradd_variable_cost_to_objective!call. The conversion math itself is now compile-time-foldable inside the specialization, so the units refactor's payoff is real and immediate. If/when the entry dispatch shows up in a profile, (4) is the lowest-cost mitigation.