Hello, NSLondon.
Sviatoslav
Kliuchev
Lead Software Engineer
JPMorgan Personal Investing
Hello, everyone! I’m Sviatoslav Kliuchev, a Lead Software Engineer at JPMorgan Personal Investing. Today we’ll talk about the time we give up every day while our projects build. I’ve prepared a small repository where you can reproduce all the measurements. I’ll show the recorded results here, and you can run the examples on your own machine after the talk.
Let’s start with the goal.
Make your
build time quicker
Make your build time quicker. Over the next seventeen minutes, I’ll share a few practical ways to answer one question sooner: does my idea work?
Why does this matter so much now?
Why build time matters
Life isn’t endless.
We have plans beyond this build.
CI/CD
Faster feedback on pull requests and releases.
AI loops
Hands up if your project builds in under two minutes. I’m happy for you — today will be a little less painful for you. Now, who waits eight to ten minutes or more? This is especially for you.
The first reason is simple: life isn’t endless. We’d like to spend it on something besides Xcode’s progress bar. Second, CI/CD: faster builds mean faster feedback on pull requests and releases. And third, AI loops. Let’s take a closer look at those.
Let’s look at one iteration.
AI tests ideas
in a loop
Hypothesis
“This should fix the bug”
Edit
Change the code
Run
Test the idea
Feedback
Errors and test results
↶ The next hypothesis depends on the previous result
Loop speed determines how many ideas an agent can test.
AI development is a sequence of hypotheses. The agent thinks a change will solve the problem, writes the code, runs a check and gets feedback. The build fails, so it fixes the code. The build passes but a test fails, so it tries another hypothesis.
This loop repeats until it gets a result. Each iteration includes time spent by the model and its tools. If validation takes up most of that time, generating code faster only helps so much. We need to shorten the wait for the next useful piece of feedback.
Let’s compare the same loop in two environments.
Web: more iterations
Edit the code
Run it
Get feedback
Every step is quick.
Engineers can test more ideas.
In a typical web development loop, every step is short: change the code, run it, see the result. That lets an engineer, or their AI agent, try a lot of ideas.
I’m talking about a fast local development loop, for example with HMR. Web development also has bundling, transforms, type checking and production builds. I’m not claiming that compilation doesn’t exist there.
Now let’s look at the same loop in iOS.
iOS: your build is the bottleneck
Edit
Compile
+ Link
This is where we wait
Run
Feedback
Colleagues with faster builds can test more ideas.
This is an upper bound: editing, running and reviewing results also take time.
In iOS, compiling the project sits between making a change and checking it. If compilation takes up most of the loop, that’s your bottleneck.
With a ten-minute build, you can complete at most six sequential iterations per hour. In practice, it’s fewer: that calculation leaves out editing the code, running tests and analysing the result. Colleagues with shorter builds can test more hypotheses in the same hour.
Let’s speed up the slowest part of the loop.
How to speed up your builds
Type checking
Make the compiler’s job easier
Compile cache
Reuse previous compilation results
Fewer packages
Merge manifests and keep your modules
Tooling
Rethink your build infrastructure
We will measure the first three techniques separately, then combine them.
We have four options: help the type checker, enable the compile cache, reduce the number of packages and, if that’s still not enough, reconsider the tooling.
I measured the first three techniques separately. For packages, there are two ways to apply the same idea: one package per feature, or one package for all internal modules.
Let’s start with one line of code.
Type checking:
how long is “reasonable”?
Type inference and overload resolution can make a short expression expensive.
The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions
Look familiar?
Does this message look familiar? The compiler can’t type-check the expression in reasonable time. What’s reasonable for the compiler can feel pretty painful to us.
Swift resolves overloads and infers types. The more connected, ambiguous choices an expression contains, the more work the solver has to do. So code that looks short isn’t necessarily quick to check.
Apple is working on this too.
https://forums.swift.org/t/recent-improvements-to-the-type-checker/87048
What changed
in Swift 6.4
The compiler
Rules out unsuitable overloads earlier.
Narrows down possible types and recomputes only affected choices.
SwiftUI in Xcode 27
ContentBuilder reduces the alternatives in builder expressions.
Also works with older deployment targets.
Complex expressions and error diagnostics can still be expensive.
Swift 6.4 includes several changes to the compiler itself. Disjunction pruning rules out overloads earlier when they clearly cannot work. Improved binding inference narrows down the possible types more precisely. Some repeated calculations now happen only after the constraints change.
Separately, the SwiftUI SDK in Xcode 27 introduced ContentBuilder. Common builder APIs are unified, giving the compiler fewer alternatives between different kinds of content.
These are two separate groups of improvements. They don’t make every expression instant: complex closures, overloads and especially diagnostics for invalid code remain difficult cases. And invalid code is exactly what an agent often needs to check.
What can we do in our projects today?
https://forums.swift.org/t/recent-improvements-to-the-type-checker/87048
Even so…
Type inference problems
still remain.
Find slow expressions in the build log.
Give intermediate values explicit types.
Split long expressions into smaller pieces.
-Xfrontend -warn-long-expression-type-checking=20-Xfrontend -warn-long-function-bodies=200Type inference can still run into problems. Diagnosing code that is already invalid can be especially difficult: the solver also tries to work out what we meant.
A practical first step is to enable diagnostics for slow expressions and functions. The thresholds on the slide are examples for finding problems, not a permanent rule for CI. Then give the compiler some help: explicit types, intermediate values and shorter expressions.
Here’s a concrete example at the level of one function.
https://forums.swift.org/t/recent-improvements-to-the-type-checker/87048
https://forums.swift.org/t/roadmap-for-improving-the-type-checker/82952
Same result. Less guesswork.
Inferred types
func value() -> Double {
let x = 1 * 2 * 3 * 4
* 5 * 6 * 7.5
return x
}—ms
Explicit types
func value() -> Double {
let x: Double = 1 * 2 * 3 * 4
* 5 * 6 * 7.5
return x
}—ms
Whole-function type checking · Swift 6.4 · median of — runs · identical output.
This slide shows the complete code that we measured, before and after. It’s a small, separate example, so these timings don’t include the package graph or the full build.
The median type-checking time for the whole function is {{expression.beforeMs}} milliseconds before and {{expression.afterMs}} milliseconds after. We ran it seven times with Swift 6.4 and fresh module caches. The timings come from -debug-time-function-bodies, and we separately verified that the results match.
We count the time for the whole function, including every new intermediate expression. Comparing the slowest old line with the slowest new piece would be misleading.
Sometimes we can avoid doing that work again altogether.
Compile cache
Just turn it on.
Compile cache. Just turn it on. When the compiler sees the same inputs again, it can return the result it saved earlier.
In Xcode, that starts with two settings.
https://developer.apple.com/documentation/xcode/build-settings-reference
Two settings in Xcode
In your project’s .xcconfig or Build Settings
COMPILATION_CACHE_ENABLE_CACHING = YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS = YES
First build
Compiles and stores the result.
Next build
Reuses it when the inputs match.
Check the build log for cache hits. New code, flags or SDKs can cause misses.
The first setting enables compilation caching. The second helps you see hits and misses in the build log. You can set them in an xcconfig file or in Build Settings.
The first build needs to populate the cache. When a task runs again with the same sources, flags, SDK and dependencies, its result can come from the cache. After enabling it, check for actual hits in your workflow.
Here’s the measured result from the demo.
https://developer.apple.com/documentation/xcode/build-settings-reference
Compile cache only:
before and after
— lines of Swift · 101 targets · one Package.swift
Cache OFF
—s
Warm cache ON
—s
Same code. Fresh build / DerivedData and module caches; only CAS retained. — runs, Debug without DWARF.
First, some context: the demo has {{project.sourceLines}} lines of Swift across 101 source files, with 100 library targets and one executable. In this comparison, everything is under one Package.swift, and the sources are identical.
With the cache off: {{cacheOnly.beforeSeconds}} seconds. After a separate step to populate the compiler cache: {{cacheOnly.afterSeconds}} seconds. Both builds have fresh build artifacts, DerivedData and module caches. Only the CAS is retained in the right-hand column, so that cache is not completely cold.
We recorded {{cacheOnly.hits}} hits and zero misses: 101 objects, 101 Swift modules and two SDK tasks. {{build.repeats}} runs, on the same machine, using Swift 6.4 and Debug without DWARF. The report includes the full method and ranges.
There are a few details to watch with paths and a shared team cache.
Cache: paths and teams
Separate branch directories
/work/feature-a → HIT /work/feature-b → MISS
Our test did not reuse the cache
across different directories.
Xcode 27 supports prefix mapping.
Configure it and verify the hits.
Sharing cache with your team
Enabling the cache
is only the first step.
Remote caching needs a separate
service and integration.
For example, through Tuist.
The path test reflects our configuration without prefix mapping.
Our control test without prefix mapping produced a hit at the original path and a miss after moving the same sources to another directory. So separate worktree directories may not automatically reuse cached results.
That isn’t a fundamental restriction on reuse between directories. Xcode 27 has Swift and Clang prefix mapping, including project prefix mapping. Check those settings against real cache hits in your project.
It would also be wrong to say that remote caching isn’t supported at all. For example, Tuist can share compilation artifacts between machines through a service and plugin integration. The COMPILATION_CACHE_ENABLE_CACHING flag alone doesn’t enable that sharing.
The next source of waiting is the package structure.
https://forums.swift.org/t/about-swift-shared-cache-across-machines/81850
Modularization + SPM
A large project. Clean Architecture.
Features
Feature packages
Layers
Data · Domain · Presentation
Nice and clean.
But this structure comes at a cost.
If you work on a large project with Clean Architecture, you probably have feature packages and separate Data, Domain and Presentation layers.
It all looks nice and clean: visible dependencies and tidy boundaries. But when every layer is a separate package, the build system pays for it.
Let’s count them.
How you end up with 100 packages
Feature01Data Feature01Domain Feature01Presentation
The demo also has a root executable package: 101 Package.swift files in total.
Thirty-three features, each with three layers: Data, Domain and Presentation. Plus one common Shared package. That gives us one hundred separate packages.
The demo also has a root executable package above them. That’s why the measurements use 101 Package.swift files. It’s the same hundred application packages plus the root, not a different project.
What happens to each of those manifests?
Package.swift needs
compiling too
Compile the Swift manifest.
Run it to get the package description.
Resolve dependencies and plan the build.
Minimal manifest on this machine
—s
Compile + link · — runs.
Full SwiftPM CLI: — s.
SwiftPM startup and manifest execution are measured separately. Target source compilation is not included.
Package.swift is a Swift program. With an empty manifest cache, it has to be compiled and run to obtain the package description. SwiftPM then connects the graph and prepares the target tasks.
I separately measured a minimal four-line manifest with no targets. On this machine, the median for compilation and linking was {{manifest.compileSeconds}} seconds, and execution took {{manifest.executeSeconds}} seconds. A complete, separate swift package dump-package invocation took {{manifest.dumpSeconds}} seconds. Seven runs, with fresh private caches.
So about half a second is accurate for the full CLI invocation in this example. But calling 0.6 seconds the minimum compilation cost of any Package.swift would be inaccurate.
Let’s multiply the part we actually measured.
This work repeats
for every package
Estimated time to compile
100 minimal manifests sequentially.
An estimate of total work. Parallelism and shared caches change elapsed build time.
If we repeated that measured minimal-manifest compilation one hundred times in sequence, it would take around {{manifest.serial100Seconds}} seconds. And that’s before compiling the targets themselves.
This illustrates accumulated work, not an exact total build time. Within a build, some work runs in parallel and can share caches. So our measurements don’t support saying that a hundred packages means at least a minute. To see the full effect, we’ll compare the project itself.
The solution is to reduce the number of manifests.
The solution
Fewer packages
One package per feature
Feature01/Package.swift target: Data target: Domain target: Presentation
Or one package for all features
Features/Package.swift target: Feature01Data target: Feature01Domain target: Feature01Presentation …
Same results. Different experience.
Keep separate targets, dependencies and the library products you need.
Instead of N times M packages, start with one Package.swift per feature. Keep separate Data, Domain and Presentation targets inside it, with explicit dependencies between them.
The next option is one internal package for all the features. Each target remains a module, so separate imports and module boundaries are preserved. You can keep the library products you need.
Same results. Different experience. In a real migration, also check package access, resources, plugins and whether any packages need independent versions.
Here’s what happened when we changed only that.
https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html#target
Packages only:
same code, fewer manifests
33 features. 100 library targets + Demo. Compiler cache OFF.
101 manifestsThree packages per feature
—35 manifestsOne package per feature
—1 manifestOne internal package
——
SwiftPM CLI, macOS, Debug without DWARF. — runs. Fresh build/cache directories; OS file cache retained.
Here, we change only how the modules are packaged. The Swift source files are byte-for-byte identical, the targets are the same and the compiler cache is off. The original version with 101 manifests takes {{packagesOnly.baselineSeconds}} seconds.
One package per feature gives us 35 manifests, including Shared and the root: {{packagesOnly.featureSeconds}} seconds. One shared manifest takes {{packagesOnly.singleSeconds}} seconds.
We reduce the work needed to load manifests and prepare the graph. The number of library modules stays the same. These are complete command timings, not a sum of individual manifest timers.
Now let’s combine the first three techniques.
All techniques combined
Same program and output. A synthetic demo project.
Baseline project101 manifests, long expressions, cache OFF
—Types + packagesOne manifest, short expressions, cache OFF
—Types + packages + cacheFresh build artifacts, warm CAS
——
Synthetic benchmark. Gains in a real project may be smaller.
The original version takes {{combined.beforeSeconds}} seconds. Explicit types and a single manifest reduce the time with a completely empty compiler cache. We get {{combined.afterSeconds}} seconds when we also allow reuse of a populated CAS.
This is a synthetic demo project with 64 deliberately long formatting functions and lots of small packages. Both bottlenecks are amplified here. In a real project, the effect may be smaller: you still have other sources, assets, linking, code generation and tests.
You can’t add up the percentages from the individual techniques. A cache hit already skips type checking, and your actual gain depends on where your project spends its time.
What if that’s still not enough?
But what if none of this
gets you the results
you need?
We’ve tried the inexpensive local changes. If the measured result still doesn’t meet the team’s needs, it’s time to discuss build infrastructure.
For example, Bazel.
Bazel
What you get
Remote cacheReuse results from colleagues and CI
Remote executionBuild and test on remote workers
Explicit module graphControl targets and dependencies
What it takes
Migration timeRules, toolchains and CI
A steep learning curveInfrastructure and IDE integration
Team retrainingA new workflow to learn and maintain
Build infrastructure needs an owner. Apple actions need a compatible macOS environment.
Bazel gives you a remote cache, so you can reuse results from teammates and CI. Remote execution lets build and test actions run on remote workers. Explicit targets and dependencies help you manage a large module graph.
But those benefits take investment. You need time for migration, rules and toolchains, and integration with the IDE and CI. Getting started is hard; the team has to learn and maintain a new workflow.
Build infrastructure needs a permanent owner. Apple actions need a compatible macOS environment. There’s no magic threshold of one hundred engineers: the return depends on your scale and how much time you’re actually losing.
That’s why it’s worth starting with the simple steps.
https://bazel.build/remote/caching
Low-cost steps
you can take today
Enable caching
Rebuild and check the hits.
Profile type checking
Find and simplify expensive expressions.
Count Package.swift files
Merge the layers of one feature first.
Record the time before each change. Repeat the same scenario afterwards.
There are three things you can do today. Enable the compile cache and check for hits in your real workflow. Add type-checking diagnostics and find the most expensive expressions. And simply count how many Package.swift files you have.
If every feature has three nearly empty packages, pick one feature, merge its manifests while keeping the targets, and measure the difference.
You don’t need to migrate the whole project at once. Get one reproducible result, check the behaviour and expand the change gradually. The goal is to get back to the next hypothesis sooner.
That’s all from me.
Thank you
Thank you! I hope your next coffee break happens because you want one, not because the compiler insists.
I’ll leave the links up, and let’s take some questions.
The first QR takes you to the demo repository: source code, benchmark scripts and the slides. The second takes you to my LinkedIn profile. You can download the project and reproduce the measurements on your own Mac.
All the numbers come from the demo project. The most useful next step is to apply the same method to your own build. Thank you — let’s take some questions.
Keep this slide visible during questions.