
Performance is an Architectural Property
Measurement identifies where resources are being consumed. Understanding why those costs exist determines where to intervene.
In a traditional min/max problem, there is always a danger of settling into a local optimum instead of a global one. Traditional performance tools can make it easy to optimize this way. Using these tools, the optimization process tends to follow these steps:
- Run a simulated but representative load through the software.
- Identify a hot path, drill down into it and identify the most expensive operations.
- Review each expensive operation and attempt to reduce its cost.
After going through this performance improvement process a few times, everything will look much better. There won't be any obvious low-hanging fruit to optimize next, and everything will look reasonably lean. Despite this, the code might be far away from optimal and be stuck at a local optimum. Why?
Because performance tools only give a partial view. They point to where to investigate, not necessarily where to optimize. They can't identify code that shouldn't exist or code that bakes in incorrect assumptions. A profiler can identify where resources are currently being spent, but it can't identify whether that cost is warranted. Getting beyond that local optimum requires a more holistic view.
Measure First
A good carpenter measures twice and cuts once. Similarly, a good performance engineer measures before optimizing. Software developers can spend a lot of time optimizing a code path that is nowhere near a hot path and will have minimal impact on overall system performance. This is not an excuse to intentionally write slow code, but a warning to avoid expending a lot of effort optimizing before measuring.
Traditional performance tools allow a developer to find where resources are being consumed. They can measure CPU usage, memory consumption, synchronization contention and other resource costs. For best results, it's important to use these tools to analyze a real-world scenario or a closely simulated one. Analyzing an unrealistic scenario can point to bottlenecks that aren't significant under real-world conditions.
When I was analyzing the performance of a file processing pipeline, I had access to statistics about the actual mix of file types seen in production. I built a representative corpus composed of this same mix and used it in my performance testing. While profiling this pipeline, I used a CPU profiler and noticed that there was a lot of time being spent in an XML library. That result was unexpected, so I measured again and saw the same thing. Now, I was confident it was not a spurious result and started investigating further.
Eventually, I found where the XML processing was happening. This pipeline received files from another process and then reported results about them. The results were serialized in XML by the pipeline process and then immediately deserialized by the other process prior to inspecting or storing them. Both processes were part of the same product. There was no requirement to use XML. It was just chosen as a convenience. In fact, there was no requirement to use a standard structured format at all.
After identifying the problem and the true requirements, I proposed an internal binary format to eliminate textual parsing entirely. Since the interprocess communication was only a product implementation detail, it had no client-facing impact. Nonetheless, it improved throughput by approximately 54% compared to using XML.
The performance tools pointed to the problem but not the solution. A narrow reading of the performance results would have suggested optimizing the XML processing. That would have treated XML as a requirement rather than questioning why it was being used at this boundary. Without going back to the requirements, the option to replace XML would not have been discovered at all.
Question the Machinery
Sometimes performance problems are spread out throughout a system. Degradation comes from the death of a thousand cuts. Layers on top of layers of abstractions add up and cost an aggregate performance penalty. This is true even when each layer, in isolation, is performance-tuned and optimized. Performance tools might not find any obvious optimization candidates because the cost is distributed across the system. The next step is to review the architectural decisions that might be imposing distributed costs.
The file processing pipeline discussed in the previous section had the ability to load third-party processors and forward them content to scan. Based on the trust levels of these processors, they could either be loaded in the main process or hosted in a child process. Each processor also needed to be able to be updated independently of the product cadence, so they needed to have a clean module boundary. In order to facilitate these requirements, the original developers created a custom cross-platform C++ ABI framework. This necessitated a lot of conversions to and from custom ABI types, which added overhead even to the in-process calls. The individual conversions and dispatches were cheap in isolation, but in aggregate, they were significant. There were also other latent costs related to the use and orchestration of the ABI objects.
Importantly, the product only ever needed to run on Windows operating systems. While possible, C++ does not have a standard way to define classes and objects that are portable across modules built by different compiler toolchains. The choice to develop a solution from scratch might have made sense if the product needed to run on non-Windows operating systems. Since only Windows needed to be supported, Microsoft COM provided an existing solution to these requirements.
In order to test the performance impact of this hypothesis, I created a small COM wrapper around one of the processors and built a simple test harness to compare the overhead of the bespoke ABI version and the COM version. Initial testing showed a marginal performance improvement, so I went ahead with the migration to COM. In the end, the custom ABI solution and the orchestration code of the ABI objects, amounting to roughly 30,000 lines of code, were deprecated and removed. Retesting the new solution found that it was roughly 9% faster than the old one. The initial testing underestimated the impact because it left many of the existing layers in place for a fairer comparison. Architectural overhead does not have to appear as a single expensive operation. Small costs imposed by an architecture can be broadly dispersed and make the architecture itself the optimization target.
Question the Optimization
Other times, a hot path might have been properly identified and an optimization put in place. Still, it's important to review the applicability of the optimization. There are many ways to optimize a hot path. The goal isn't always to find and implement the fastest possible solution. It's to find a solution that meets the performance requirements at a reasonable engineering cost. A marginal performance improvement might not justify the future maintenance cost of tens of thousands of lines of code.
I was working on a product that had a C# process send data to a C++ process for processing. The interprocess communication (IPC) had already been identified as a potential bottleneck. A custom IPC solution had been built that used various techniques depending on file size. For example, it would use memory-mapped files for small files and socket communication for large files. This solution worked well and met the performance requirements, but was never benchmarked against alternative approaches.
I began benchmarking the custom IPC solution against alternatives, expecting to prove its performance advantages. My first attempt was to compare it to a simple .NET/COM interop that sent every file one at a time. The custom solution outperformed it easily. Next, I grouped the files in the test set by size and retested. These results revealed something different. The custom solution performed even better for small files, but had almost no advantage over medium and large files. This was an unexpected result and piqued my interest. I hypothesized that the .NET/COM approach had a fixed overhead cost for each dispatch call independent of file size. This would be significant relative to the size-dependent transport costs for small files. I decided to buffer multiple small files together until either a specific size was reached or time elapsed. This would allow the per-dispatch costs to be amortized across multiple files. After implementing this and rerunning the tests, the performance matched that of the custom IPC solution.
It's not enough to identify a performance problem and a good solution. Alternative solutions need to be considered before undertaking a big effort. Sometimes, identifying the problem too broadly will not lead to the best solution. In this case, the problem was originally framed as IPC being the bottleneck that needed optimization. In reality, the performance problem was isolated to small files, which had a relatively high fixed per-dispatch overhead. In the first case, the goal was to optimize each individual IPC call. In the second, the goal was to reduce the number of IPC calls. This reframing led to a different solution and the reduction of roughly 20,000 lines of custom IPC code without any measurable performance degradation.
Conclusion
Many times, performance engineering is viewed as mechanistic. Find a slow function and make it faster. While this is a valid approach, it is much too narrow. Constraining a performance investigation to existing code and structures will leave a lot of potential optimizations out of scope.
It's important to measure first. Find things that are really expensive instead of focusing on things that might be expensive. Once something is identified, analyze it further. Determine why the operation is expensive and whether the expense is a result of satisfying a requirement or just an implementation detail. If the expensive work isn't required, replace or remove it.
Examine the architectural decisions for any undesirable costs. Look for small costs imposed by the architecture that are individually insignificant but add up in aggregate. Find ways to perform expensive work less frequently. Benchmark any optimizations already in place against alternatives.
In the end, performance tools can only identify where resources are being used. Engineering judgment determines what to do with that information. Is there something to optimize, eliminate, replace or call less frequently?
Complexity Has to Earn Its Place
The right level of software complexity is a Goldilocks problem. Too little and too much can both create technical debt. Judgment matters even more with AI-assisted coding.
What Does This Test Actually Prove?
Different testing strategies reduce different sources of uncertainty. Production evidence can further improve both the product and how it is tested.
