repeated-pure-intrinsic
Status: shipped (Phase 4 since v2.0.4 — was Phase 2 in v2.0.3) — see CHANGELOG. Requires
enableControlFlow=truein the LSP /LintOptions.
What it detects
Two or more syntactically-identical calls to an expensive pure intrinsic within the same function body, when no intervening mutation could have changed the argument's value. Allowlist:
sqrt, rsqrt, length, normalize, pow, exp, exp2, log, log2, log10, sin, cos, tan, asin, acos, atan, atan2, sinh, cosh, tanh.
Cheap intrinsics (min, max, abs, dot, lerp, clamp, step, smoothstep, saturate) are intentionally excluded — the duplication signal is too noisy and the cost too low to be worth flagging.
The detector tracks three classes of mutation between two candidate calls A and B and skips the report if any apply to the argument:
- Direct assignment to an identifier referenced in the argument list (
x = ...,x += ...,x.field = ...). - Increment / decrement of such an identifier (
x++,--x). - Calls to non-allowlisted functions that pass any argument identifier — conservatively treated as a wildcard barrier because HLSL
out/inoutparameters cannot be detected from the AST alone.
Two AST-based control-flow refinements (added in v2.0.4) make this function-scope:
- Dead-branch relaxation. A mutation that sits inside a block ending with
return/discard/break/continuewhose end byte precedes B does NOT suppress — the mutation cannot reach B on any path, so A and B are still genuine duplicates. Catches early-return guards and PSdiscardpatterns the v2.0.3 detector missed. - Disjoint
if/elsesuppression. When A and B sit in sibling branches of the sameif_statement(consequence vs.else_clausebody), at most one runs per execution and the "duplicate" report is misleading. The detector skips the pair.
Why it matters on a GPU
Modern HLSL / Slang compilers (DXC, Slang's downstream DXIL / SPIR-V emitters) already perform common-subexpression elimination on pure intrinsics at -O1, so the runtime cost of the duplicate is usually zero — the compiler hoists it for you. The rule's value is clarity and intent, not raw performance:
- Duplicated calls signal confused authorship: the developer either forgot they already computed the value, or copy-pasted a sub-expression without consolidating.
- Hand-written breakpoint placement, SSA inspection, and shader-debugger stepping all behave better when the call appears once with a named result.
- Fast-math / FMA folding tweaks (e.g.
precise,[FastMath]attribute variants) are easier to reason about with a single named call. - On older / lower-quality compiler pipelines (mobile vendor toolchains, cross-compilers with weaker CSE), the duplicate may actually emit twice — flagging at the source level is cheap insurance.
Examples
Bad
float ior_index(float f0, float maxIor)
{
float iorIndex = 1.0;
if (f0 < 1)
{
// sqrt(f0) computed twice in the same expression.
float ior = (sqrt(f0) + 1.0f) / (1.0f - sqrt(f0));
iorIndex = saturate((ior - 1.0f) / (maxIor - 1.0f));
}
return iorIndex;
}Good
float ior_index(float f0, float maxIor)
{
float iorIndex = 1.0;
if (f0 < 1)
{
float s = sqrt(f0);
float ior = (s + 1.0f) / (1.0f - s);
iorIndex = saturate((ior - 1.0f) / (maxIor - 1.0f));
}
return iorIndex;
}Not flagged (mutation between calls)
float foo(float x)
{
float a = sqrt(x);
x = x * 2.0; // x changed — calls are NOT duplicates
float b = sqrt(x);
return a + b;
}Options
none
Fix availability
suggestion — Hoisting is mechanical but the rule does not auto-rewrite because (a) the new local needs a name (s? sqrtF0?), (b) hoisting above a branch may move work that was guarded for a reason, and (c) precision-sensitive ordering may rely on the duplicate computation in rare numerical-analysis code. We surface the duplicate and let the developer confirm.
Limitations
- Lexical-scope shadowing. Function-scope detection treats all references to the same identifier name as the same value, so a pathological shadowing pattern (
float x; sqrt(x); { float x; sqrt(x); }) may produce a false positive. Suppress with// shader-clippy: allow(repeated-pure-intrinsic)if encountered. - Aliased mutation through pointers / structures. HLSL has no pointer aliasing in user code, but a
cbufferorRWStructuredBufferfield accessed through different paths may be silently mutated by an interveningLoad/Store. The rule does not track these — false positives are possible. Same suppression workaround applies.
See also
- HLSL intrinsic reference: Intrinsic Functions
- Companion blog post: not yet published