DEV Community

Cover image for Can Big-O Complexity Be Detected at Compile Time?
Rodrigo de Oliveira
Rodrigo de Oliveira

Posted on

Can Big-O Complexity Be Detected at Compile Time?

When we learn algorithm analysis, we usually practice by looking at a piece of code and asking questions such as: how many times does this loop run? Is there another loop inside it? Does this search scan an entire collection? Does this recursion split the problem in half?

After a while, we start recognizing these patterns almost automatically.

A for loop iterating over a collection tends to suggest O(n). Two nested loops may indicate O(n²). Binary search points us toward O(log n). An efficient sorting algorithm usually lands around O(n log n).

That raises an interesting question:

If we can recognize these patterns by reading code, could the compiler do the same?

That is exactly the question that led me to explore building a Roslyn analyzer capable of estimating algorithmic complexity at compile time.

The short answer is: yes, to a certain extent.

The more interesting answer is understanding where that “to a certain extent” begins.


Big-O does not measure how long a method takes

Before talking about compilers, it is worth clarifying something important.

Big-O does not measure time in milliseconds.

When we say an algorithm is O(n²), we are not saying it is necessarily slow. We are describing how its computational cost grows as the input size increases.

Consider:

for (var i = 0; i < items.Count; i++)
{
    Process(items[i]);
}
Enter fullscreen mode Exit fullscreen mode

If we assume Process has constant cost, we have approximately:

T(n) = n
Enter fullscreen mode Exit fullscreen mode

Therefore:

O(n)
Enter fullscreen mode Exit fullscreen mode

Now add another loop:

foreach (var item in items)
{
    foreach (var other in items)
    {
        Compare(item, other);
    }
}
Enter fullscreen mode Exit fullscreen mode

In this case:

T(n) = n × n
Enter fullscreen mode Exit fullscreen mode

which gives us:

O(n²)
Enter fullscreen mode Exit fullscreen mode

Big-O ignores constants and less relevant details so we can focus mainly on the rate of growth.

That is why it remains useful even across completely different machines.

MIT, for example, introduces asymptotic complexity and recurrences early in its algorithms curriculum precisely because they provide a language for reasoning about growth without depending on specific hardware.

But that abstraction also creates the first challenge for automated analysis: to infer Big-O, we need to understand not only the syntax of the code, but also the meaning of the operations being executed.


The compiler knows much more about our code than it seems

This is where Roslyn makes the idea interesting.

The C# compiler does not see a source file as plain text.

It builds a structured representation of the program called a Syntax Tree. The syntax tree represents declarations, expressions, loops, method calls, conditionals, and practically every other language construct.

That means an analyzer does not need to search for strings such as:

"foreach"
Enter fullscreen mode Exit fullscreen mode

It can ask directly:

Is there a ForEachStatementSyntax here?

More importantly, Roslyn also provides a Semantic Model.

That difference is significant.

Imagine:

items.Contains(value);
Enter fullscreen mode Exit fullscreen mode

Looking only at the text, we do not know very much.

Contains could belong to:

List<T>
HashSet<T>
Dictionary<TKey, TValue>
IEnumerable<T>
MyCustomCollection
Enter fullscreen mode Exit fullscreen mode

And those operations may have very different costs.

The Semantic Model can resolve the actual symbol referenced by the expression and reveal types, methods, arguments, and relationships between program elements. Roslyn's own documentation explains that the syntax tree alone is not enough to determine what an identifier actually refers to; that responsibility belongs to the semantic layer.

It is precisely this combination of syntax and semantics that makes much more sophisticated analysis possible.


A seemingly innocent example

Consider this code:

foreach (var item in items)
{
    if (otherItems.Contains(item))
    {
        Process(item);
    }
}
Enter fullscreen mode Exit fullscreen mode

If otherItems is a List<T>, Contains may scan the list looking for the element.

If items has n elements and otherItems has m, we can model the cost approximately as:

O(n × m)
Enter fullscreen mode Exit fullscreen mode

If both collections grow at roughly the same rate:

O(n²)
Enter fullscreen mode Exit fullscreen mode

Now change the data structure:

HashSet<int> otherItems;
Enter fullscreen mode Exit fullscreen mode

The exact same line of code:

otherItems.Contains(item);
Enter fullscreen mode Exit fullscreen mode

has a very different expected algorithmic behavior.

The text barely changed.

The semantics changed significantly.

A complexity analyzer therefore needs to resolve the symbol and recognize known operations from the libraries being used.

In ComplexityAnalysis.Analyzers, this is one of the strategies I use: known BCL and LINQ operations are identified by the symbol resolved by Roslyn, not simply by the method name. This prevents a custom method named Contains from automatically being treated as if it were List<T>.Contains.

That detail may seem small, but it is exactly the kind of distinction that separates an interesting demo from a usable analysis tool.


Loops are the easy part

Finding loops is relatively straightforward.

The challenge begins when we need to understand what happens inside them.

Consider:

foreach (var item in items)
{
    Calculate(item);
}
Enter fullscreen mode Exit fullscreen mode

What is the complexity?

We do not know.

If:

Calculate(item);
Enter fullscreen mode Exit fullscreen mode

is O(1), then we have:

O(n)
Enter fullscreen mode Exit fullscreen mode

But if Calculate scans another collection of size m, we may have:

O(n × m)
Enter fullscreen mode Exit fullscreen mode

Or perhaps:

O(n²)
Enter fullscreen mode Exit fullscreen mode

depending on the relationship between the inputs.

That is why a more sophisticated analyzer needs to move beyond purely local analysis.


Interprocedural analysis

This is where interprocedural analysis comes in.

Imagine:

void Process(IEnumerable<int> items)
{
    foreach (var item in items)
    {
        Search(item);
    }
}
Enter fullscreen mode Exit fullscreen mode

And:

void Search(int item)
{
    foreach (var value in values)
    {
        if (value == item)
            return;
    }
}
Enter fullscreen mode Exit fullscreen mode

Analyzing only Process is not enough.

We need to follow the call to Search.

We can think of it like this:

Process
    |
    +-- loop n
            |
            +-- Search
                    |
                    +-- loop m
Enter fullscreen mode Exit fullscreen mode

Therefore:

O(n × m)
Enter fullscreen mode Exit fullscreen mode

This kind of analysis can reveal some interesting relationships.

For example:

A → B O(n)
Enter fullscreen mode Exit fullscreen mode

results in:

A O(n)
Enter fullscreen mode Exit fullscreen mode

While:

loop n → B O(n)
Enter fullscreen mode Exit fullscreen mode

results approximately in:

O(n²)
Enter fullscreen mode Exit fullscreen mode

ComplexityAnalysis.Analyzers performs this kind of analysis within defined limits, following reachable methods and substituting called-method parameters with caller inputs when that can be done safely.

But there is a very important word here:

limits.

An analyzer that runs during compilation cannot explore the application's entire call graph indefinitely.


The analyzer also needs to be fast

There is an interesting irony in all of this.

It would be strange to build a performance analyzer that made compilation extremely slow.

Roslyn analyzers can run while we are writing code and during the build. That means analysis cost matters. Roslyn itself supports concurrent execution of analyzer actions to improve performance, provided the analyzer has been designed to operate safely in parallel.

A practical tool therefore needs to enforce analysis budgets.

For example:

maximum call depth = 5
methods analyzed per root = 32
Enter fullscreen mode Exit fullscreen mode

If the analysis exceeds that budget, it needs to stop.

That decision matters because there is a difference between:

building a theoretically impressive analyzer

and:

building an analyzer that developers are willing to keep enabled in Visual Studio.


And then we get to recursion

Recursion makes things even more interesting.

Consider:

int Factorial(int n)
{
    if (n <= 1)
        return 1;

    return n * Factorial(n - 1);
}
Enter fullscreen mode Exit fullscreen mode

We can represent its cost with a recurrence:

T(n) = T(n - 1) + O(1)
Enter fullscreen mode Exit fullscreen mode

which gives us:

O(n)
Enter fullscreen mode Exit fullscreen mode

Now consider divide and conquer:

T(n) = 2T(n/2) + n
Enter fullscreen mode Exit fullscreen mode

Applying the Master Theorem:

O(n log n)
Enter fullscreen mode Exit fullscreen mode

Another example:

T(n) = 3T(n/2) + n
Enter fullscreen mode Exit fullscreen mode

results approximately in:

O(n^1.585)
Enter fullscreen mode Exit fullscreen mode

So an analyzer can go beyond simply counting loops. It can recognize certain recursive patterns, construct a recurrence, and attempt to solve it.

In the project I have been developing, support is intentionally limited to known families: simple reduction, some exponential recurrences, forms compatible with the Master Theorem, and a restricted subset of Akra-Bazzi.

The important word, once again, is restricted.

Because trying to solve every possible recurrence quickly takes us into very different territory.


So why can't we infer everything?

This is where we reach the theoretical limit.

It is tempting to imagine an analyzer that could receive any program and correctly answer:

This method is O(1).
This one is O(log n).
This one is O(n).
This one is O(n²).
Enter fullscreen mode Exit fullscreen mode

For any arbitrary piece of code.

That is not possible in general.

Static program analysis runs into fundamental limits related to computability. The halting problem shows that there is no algorithm capable of correctly deciding, for every program and input, whether that program will terminate.

More general results, such as Rice's theorem, show that non-trivial semantic properties of programs are undecidable in general. Cornell's program analysis material summarizes the practical consequence particularly well: static analyses need to work with approximations.

That completely changes the question.

Instead of asking:

“How can we determine the complexity of any program?”

we should ask:

“For which structures can we produce a sufficiently safe conclusion?”

That is a much more productive question.


Sometimes “I don't know” is the best answer

This may be the principle I like most about this kind of tool.

Consider:

foreach (var item in items)
{
    ExecuteSomething(item);
}
Enter fullscreen mode Exit fullscreen mode

If ExecuteSomething lives in an external library the analyzer knows nothing about, there are several options.

It could simply assume:

O(1)
Enter fullscreen mode Exit fullscreen mode

But that could be completely wrong.

It could assume:

O(n)
Enter fullscreen mode Exit fullscreen mode

But that would also be arbitrary.

A much safer alternative is to return:

Unknown
Enter fullscreen mode Exit fullscreen mode

In other words:

“I don't have enough information to state the complexity.”

That may sound less impressive, but it is an important characteristic of serious analysis tools.

Constant false positives destroy trust.

After a while, developers start ignoring the analyzer.

That is why I would rather have a tool miss some cases than invent certainty.

There is an interesting parallel with static analysis in general: when a property cannot be determined precisely, we need to define how we want to approximate it. Compiler and program analysis courses deal directly with this trade-off between precision, decidability, and conservatism.


Big-O does not replace other metrics either

Another important point: algorithmic complexity is not the same thing as code quality.

A method can be:

O(1)
Enter fullscreen mode Exit fullscreen mode

and still be almost impossible to understand.

It may contain dozens of conditionals, multiple levels of nesting, too many responsibilities, and an enormous signature.

That is why it makes sense to look at different metrics independently.

Cyclomatic complexity attempts to represent independent control-flow paths.

Cognitive Complexity attempts to approximate the effort required to understand a given flow. SonarSource created this metric specifically to address aspects of understandability that are not well represented by cyclomatic complexity alone.

We can also track:

nesting depth
NLOC
statement count
parameter count
token count
Enter fullscreen mode Exit fullscreen mode

None of these metrics replaces the others.

They answer different questions.

Big-O essentially asks:

“How does cost grow with the input?”

Cognitive Complexity asks something closer to:

“How difficult is this flow to follow mentally?”

Combining those two ideas into a single score would probably create more confusion than insight.


Where this becomes genuinely useful

The goal of detecting complexity at compile time should not be to attach an academic label to every method.

The real value appears when we can detect dangerous changes in algorithmic behavior.

Imagine someone writes:

foreach (var customer in customers)
{
    if (blockedCustomers.Contains(customer.Id))
    {
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

During development, the dataset is small and everything runs quickly.

In production:

customers = 100,000
blockedCustomers = 80,000
Enter fullscreen mode Exit fullscreen mode

Now that data structure choice starts to matter.

If blockedCustomers is a list, the algorithm may perform an enormous number of comparisons.

Choosing a more appropriate data structure can completely change the expected behavior.

The analyzer does not need to prove the system's exact performance.

It needs to be able to say:

“There is a linear operation inside an iteration that depends on input size. You may want to take a closer look.”

That kind of feedback is valuable precisely because it happens before production, before benchmarking, and potentially even before code review.


Static analysis does not eliminate benchmarks

It is also important not to make a promise the tool cannot keep.

An O(n) method may be slower than an O(n²) method for small inputs.

Allocation, cache locality, I/O, branch prediction, concurrency, GC, databases, networks, JIT compilation, and many other factors influence real-world performance.

Big-O is about asymptotic growth.

Benchmarking is about concrete behavior under specific conditions.

Tracing and profiling show what actually happened during execution.

These tools complement each other.

I like to think about it this way:

Static analysis
    ↓
Where might there be a problem?

Benchmark
    ↓
What is the actual cost?

Profiling / tracing
    ↓
Where is execution time actually being spent?
Enter fullscreen mode Exit fullscreen mode

Using one does not eliminate the need for the others.


The compiler as an architectural feedback tool

Exploring Big-O through Roslyn led me to a broader conclusion.

Compilers do not have to exist only to transform source code into assemblies.

Roslyn turns the compiler into a platform on top of which we can build tools that understand program syntax, symbols, and semantics. Microsoft itself presents this as one of the platform's core goals.

That lets us move many checks closer to the moment when a decision is made.

Instead of discovering certain problems in Sonar after a push, in code review a few hours later, or in production weeks later, we can provide feedback while the developer is still writing the method.

That idea is particularly interesting from an architecture perspective.

Whenever an architectural rule can be expressed objectively, there is a possibility of turning it into:

analyzer
architecture test
CI rule
source generator
linter
policy
Enter fullscreen mode Exit fullscreen mode

It is a shift from passive documentation to executable feedback.

Not every architectural decision can or should become an automated rule.

But some certainly can.


What I learned while exploring this idea

The original question seemed simple:

“Can Big-O be detected at compile time?”

After exploring the problem, I think a better formulation is:

“Which complexity properties can we infer safely enough to provide useful feedback to developers?”

The difference matters.

We are not trying to build an oracle.

We are building a conservative analysis of a known subset of the language.

Loops, known operations, LINQ, certain interprocedural calls, and some families of recurrences can produce very useful results.

Dynamic code, difficult-to-resolve dispatch, unknown libraries, complex data dependencies, and structures outside the known model may remain Unknown.

And that is fine.

In tools like this, knowing when not to make a claim is part of the quality of the analysis.


Key takeaways

Big-O can be partially inferred at compile time because Roslyn provides much more than text: we have syntax trees, symbols, types, and semantic information.

The problem becomes interesting when we move beyond counting loops and start understanding operations, method calls, and recurrences.

There is no algorithm, however, that can perfectly determine semantic properties like these for every arbitrary program. Practical tools need to work with known subsets and conservative approximations.

An Unknown result can be far better than a fabricated estimate.

And perhaps most importantly, Big-O analysis does not replace benchmarks, profiling, cyclomatic complexity, or Cognitive Complexity. Each technique answers a different question.

The real gain is being able to move certain performance and design signals much closer to the moment when code is being written.


Where to go next

For anyone who wants to explore the topic in more depth, I would recommend a few directions.

  • Microsoft Learn — .NET Compiler Platform SDK / Roslyn APIs: start with the Syntax Tree, Semantic Model, Symbols, and Diagnostic Analyzer APIs. The official documentation even includes a complete tutorial for building an analyzer and a code fix.
  • MIT OpenCourseWare — Introduction to Algorithms: excellent material for strengthening your understanding of asymptotic analysis, recurrences, divide and conquer, and algorithm fundamentals.
  • Introduction to Algorithms — Cormen, Leiserson, Rivest, and Stein (CLRS): still one of the best references for algorithms, asymptotic analysis, and recurrences.
  • Compilers: Principles, Techniques, and Tools — Aho, Lam, Sethi, and Ullman: useful for understanding compilers, intermediate representations, program analysis, and optimization.
  • Cornell Program Analysis materials: particularly useful for understanding data-flow analysis, conservative approximations, undecidability, and the theoretical limits of static analysis.
  • Cognitive Complexity by SonarSource: a useful read for understanding why algorithmic complexity, cyclomatic complexity, and understandability are related but distinct concerns.

If you want to look at a practical implementation of these ideas, ComplexityAnalysis.Analyzers applies this reasoning with Roslyn to Big-O analysis, interprocedural calls, selected forms of recursion, and complementary complexity metrics, always preferring Unknown when a safe conclusion cannot be reached.

In the end, perhaps the most interesting part of this exercise is not automatically determining whether a method is O(n) or O(n²).

It is realizing how much knowledge is available at compile time, and how many engineering decisions we can turn into useful feedback before code ever reaches production.

Top comments (0)