Skip to content

Conversation

@d-ronnqvist
Copy link
Contributor

@d-ronnqvist d-ronnqvist commented Nov 17, 2025

Bug/issue #, if applicable:

Summary

This is a series of low-level optimizations regarding the use of SourceLanguage and particularly Set<SourceLanguage>.

Note

These performance improvements (see below) adds a limitation that a single docc convert call can't define more than 64 different SourceLanguage values. However, considering that C/C++/Objective-C is rolled into one and that anything more than 2 languages in the same project is very rare—and anything above 3 languages being practically unheard of—I don't think this limitations is going to be impactful in practice.


These ideas came from a realization that DocC uses SourceLanguage almost exclusively for equality checks in one of 3 forms:

  • ==(_:_:) comparisons between full values
  • comparisons between only the string id properties
  • set-algebra operations on Set<SourceLanguage> (which does hash(into:) and ==(::)` behind the scenes.

I confirmed this hypothesis by adding a local counter whenever a SourceLanguage values was created, checked for equality, checked for comparison, hashed, and whenever any of its properties were accessed. This showed that across a full build, DocC does on average ~50 ==(_:_:) calls per page, on average ~140 hash(into:) calls per page, and on average ~100 id accesses page page.

Based on these numbers I hypothesized that it would be worthwhile to optimize SourceLanguage for quick comparisons at the cost of slower accesses of name and other properties.

At first I though about only storing the id string in the structure and accessing the other properties through indirect storage but then I thought that—because the most common use of SourceLanguage values is to put them in a Set and because in practice, projects are expected to have very few different languages (low single digits)—if the identifier was numeric, sets of languages could be represented as bit set. Using a private numeric ID would mean that the simpler someLanguage == .swift would be faster than someLanguage.id == "swift" which a lot of existing code was doing.

I reimplemented the internal of SourceLanguage in 10dabb4 and 4fdb68c. Then in f27559e, 4caf109, 090cad1, 3e1396a, and b9a6030 I generalized and improved the exist fixed-width bit set type—that DocC uses for type signature disambiguation—to finally be able to add a bit-set backed type that represents a "set" of
source languages in 6ac19ea.

After that; 5273e94, d6e63ae, and 3d3d580 each updated other existing code in DocC to favor full SourceLanguage comparisons and favor the bit-set backed "set" type in internal implementation details.

Trading id accesses for ==(_:_:) checks like this, increased the number of ==(_:_:) checks by ~3× (on average ~150 calls per page) but reduced the number of id accesses by ~3× (on average ~30 calls per page) ~4.5× (on average ~20 calls per page) after 9c8a9c1. The remaining id calls is largely caused by the RenderJSON code which uses string identifiers in an enum that can't would require source breaking changes to update. Because existing API need to surface Set<SourceLanguage> API externally, while using a bit-set internally, the number of SourceLanguage.hash(into:) calls increased by ~1.3×, but the new hash(into:) implementation is ~10× faster, so that's still a net-positive.

Additionally, the changes to use the new bit-set backed type for "sets" of languages resulted in a large number of method calls moving from Set<SourceLanguage> to the new SmallSourceLanguageSet. For example:

  • ~25 inset(_:) calls per page, which is ~15× faster in micro benchmarks
  • ~10 contains(_:) calls per page, which is ~100× faster in micro benchmarks
  • ~10 intersection(_:) calls per page, which is >1000× faster in micro benchmarks
  • ~10 min() calls per page, which is ~150× faster in micro benchmarks

In aggregate, on the scale of an entire documentation build, these add up to a small but measurable improvement. In one large (~10k page) Swift-only framework I measured these time and memory improvements (on my machine):

┌──────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Metric                                   │ Change          │ main                 │ current              │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Duration for 'convert-total-time'        │ -2,177 %¹       │ 5,457 sec            │ 5,338 sec            │
│ Duration for 'documentation-processing'  │ -3,754 %²       │ 2,712 sec            │ 2,61 sec             │
│ Duration for 'finalize-navigation-index' │ no change³      │ 0,041 sec            │ 0,041 sec            │
│ Peak memory footprint                    │ -3,423 %⁴       │ 929,8 MB             │ 898 MB               │
│ Data subdirectory size                   │ no change       │ 169,3 MB             │ 169,3 MB             │
│ Index subdirectory size                  │ no change       │ 1,5 MB               │ 1,5 MB               │
│ Total DocC archive size                  │ no change       │ 197,3 MB             │ 197,3 MB             │
│ Topic Anchor Checksum                    │ no change       │ 78abcd6aed9cbccaa983 │ 78abcd6aed9cbccaa983 │
│ Topic Graph Checksum                     │ no change       │ 38eaf266fdc697658430 │ 38eaf266fdc697658430 │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────┘

In another large (~10k pages) framework with both Swift and Objective-C project symbols I measured similar (~2%) improvements.

Dependencies

None.

Testing

Nothing in particular. This isn't a user-facing change.

Checklist

Make sure you check off the following items. If they cannot be completed, provide a reason.

  • Added tests
  • Ran the ./bin/test script and it succeeded
  • [ ] Updated documentation if necessary

@d-ronnqvist
Copy link
Contributor Author

@swift-ci please test

@d-ronnqvist
Copy link
Contributor Author

@swift-ci please test

}

@Test()
func testCombinations() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: This is a moved test (and implementation) from before.


struct FixedSizeBitSetTests {
@Test
func testBehavesSameAsSet() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: This is a moved test (and implementation) from before.

@d-ronnqvist
Copy link
Contributor Author

@swift-ci please test

Copy link
Contributor

@patshaughnessy patshaughnessy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work! Just some naming suggestions and a few questions...

private static func _accessInfo(id: UInt8) -> _SourceLanguageInformation {
let (unknownIndex, isKnownLanguage) = id.subtractingReportingOverflow(SourceLanguage._numberOfKnownLanguages)
return if isKnownLanguage {
_knownLanguages[Int(id)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does Swift require a cast here? We can't index an array using UInt8?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. The Index type for Array is plain Int so we have to cast the id value.

.map { $0.lowercased() }
.contains(id)
private static func knownLanguage(withName name: String) -> SourceLanguage? {
switch name.lowercased() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be ideal not to have to repeat all of the language names like this. Is there a way to refactor this to iterate over the _knownLanguages array somehow?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this could be implemented as an iteration over _knownLanguages like this:

let name = name.lowercased()
let index = _knownLanguages.firstIndex(where: {
    $0.name == name
})
return index.map { SourceLanguage_new(_id: UInt8($0)) }

Very similarly, _knownLanguage(withIdentifier:) below could be implemented as an iteration like this:

let id = id.lowercased()
let index = _knownLanguages.firstIndex(where: {
    $0.id == id || $0.idAliases.contains(id)
})
return index.map { SourceLanguage_new(_id: UInt8($0)) }

I had speculated that the switch implementations would be faster—thinking that the compiler would have more information to go on to optimize the code—but I didn't actually try and measure anything until now.

With the switch cases in the current code, the Swift compiler—when compiling with optimizations (a release build)—creates assembly that corresponds to a series of if checks one after another whereas with the firstIndex(where:) implementation, it creates assembly that corresponds to a basic loop. Essentially you can think of the difference as being between a loop that's been unrolled and one that hasn't been.

In micro benchmarks, the switch implementation for knownLanguage(withName:) is ~2.5× faster than the iteration implementation for known values (e.g. "Swift") and ~2× faster for unknown values (e.g. "Banana"). For _knownLanguage(withIdentifier:) the switch implementation is about ~4.5× faster for known values and ~4 × times faster for unknown values.


Because both initializers are being called quite frequently (>10 times per page), these differences could add up.

Also, because the list of known languages is unlikely to change frequently (possibly not for years), I find that the little bit of code duplication within this file is worth it for these initializers.


// MARK: SourceLanguage Set

package struct SmallSourceLanguageSet: Sendable, Hashable, SetAlgebra, ExpressibleByArrayLiteral, Sequence, Collection {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use a separate Swift file for SmallSourceLanguageSet ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It needs to be defined in the same file in order to be able to access _id and init(_id:) which have fileprivate access. The alternative would be to increase the to internal access so that (all) other files in this module can access them.

// MARK: SourceLanguage Set

package struct SmallSourceLanguageSet: Sendable, Hashable, SetAlgebra, ExpressibleByArrayLiteral, Sequence, Collection {
// There are a few different valid ways that we could implement this, each with their own tradeoffs.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And could you just name this SourceLanguageSet ? Do we need "Small" in the name?

Copy link
Contributor Author

@d-ronnqvist d-ronnqvist Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intention was to have some kind of indication that it's has a size limitation, unlike a regular Set<SourceLanguage> would. I thought of prefixing it with "FixedWidth" but "Small" felt less technical.

self.init(bundleID: bundleID, path: path, fragment: fragment, _smallSourceLanguages: .init(sourceLanguages))
}

init(bundleID: DocumentationBundle.Identifier, path: String, fragment: String? = nil, _smallSourceLanguages: SmallSourceLanguageSet) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you just name this sourceLanguages ? It's a bit odd reading "small" at every call site, and also why the underscore?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can rename the parameter for the initializer because overloads can be distinguished by their parameter types but the property needs to be named something other than sourceLanguages because that's what the public Set<SourceLanguage> property is already called and renaming that or changing its type would be an API breaking change.

XCTAssertEqual(SourceLanguage(knownLanguageIdentifier: "objc"), .objectiveC)
XCTAssertEqual(SourceLanguage(knownLanguageIdentifier: "c"), .objectiveC)
struct SourceLanguageTests {
@Test(arguments: SourceLanguage.knownLanguages)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of parameterized tests!

@d-ronnqvist
Copy link
Contributor Author

@swift-ci please test

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants