Привет, Хабр! На связи Кристиан Бенуа, iOS-разработчиĸ в Т-Банĸе. Быстродействие мобильных приложений — один из критериев, влияющих на успех не только приложения, но и всего бизнеса. Проводилось множество исследований, где оптимизации в сотни миллисекунд увеличивали конверсию и другие важные метрики приложения.

Особое внимание должно уделяться производительности кода в стандартной библиотеке языка, так как этот код используется почти во всех приложениях, которые написаны на этом языке.

Например, Codable в Swift стал стандартом для работы с сериализацией и десериализацией после выхода Swift 4. Многие приложения используют Codable для десериализации сетевых ответов, сохранения данных в UserDefaults или для записи данных в файлы. Поэтому производительность Codable в целом, а особенно JSONDecoder/JSONEncoder влияет на производительность большей части iOS-приложений.

В статье сосредоточимся на анализе производительности внутренностей Codable: KeyedDecodingContainer, KeyedEncodingContainer, в особенности на JSONDecoder/JSONEncoder.

Обсудим, как сказывается генерация CodingKey-ев на размере приложения и на его общей производительности. Найдем ошибки Apple при проектировании Codable и разберемся, почему Apple не смогли это заметить на своих бенчмарках.  

Расскажу, как мы сделали pull request в swift-foundation и внесли несколько оптимизаций в JSONDecoder/JSONEncoder, ускорив сериализацию и десериализацию в два раза. В конце обсудим, как получить эту оптимизацию без ограничений по версии iOS и насколько можно ускорить работу с JSON в приложении.

JSONDecoder/Encoder не такой быстрый

При профилировании старта нашего мобильного приложения с помощью Xcode Instruments (Time Profiler) мы обнаружили, что суммарно JSONDecoder и JSONEncoder занимают почти 840 мс. Это удивило нас, ведь на старте мы парсим относительно небольшие объемы данных.

Сколько времени уходит на JSONDecoder.decode от запуска до показа главного экрана нашего приложения (iPhone 13, iOS 17.2)
Сколько времени уходит на JSONDecoder.decode от запуска до показа главного экрана нашего приложения (iPhone 13, iOS 17.2)
Сколько времени уходит на JSONEncoder.encode от запуска до показа главного экрана нашего приложения (iPhone 13, iOS 17.2)
Сколько времени уходит на JSONEncoder.encode от запуска до показа главного экрана нашего приложения (iPhone 13, iOS 17.2)

Мы давно подозревали, что производительность JSONDecoder и JSONEncoder оставляет желать лучшего: часто приходилось переносить парсинг на фоновые потоки или выполнять его лениво. После замеров сомнений не осталось: узкие места есть, причем значительные.

На скриншотах видно: почти 80% времени в операциях уходит на метод swift_conformsToProtocolMaybeInstantiateSuperclasses.

Метод swift_conformsToProtocolMaybeInstantiateSuperclasses проверяет, соответствует ли тип type протоĸолу protocol. Если соответствие найдено, он возвращает десĸриптор соответствия протоĸолу (protocol conformance descriptor), ĸоторый описывает, ĸаĸ тип реализует этот протоĸол. 

В protocol conformance descriptor-е содержится информация:

  • О каком типе идет речь.

  • Какой протокол реализует.

  • Где лежит protocol witness table, в которой хранятся реализации протокольных методов для данного типа.

В основной ветке выполнения метода происходит линейный поисĸ нужного protocol conformance descriptor-а в массиве всех protocol conformance descriptor-ов. А в нашем приложении protocol conformance descriptor-ов уже более 200 000.

Более подробно про этот метод можно прочитать в другой моей статье. Здесь же мы сосредоточимся на поиске узких мест в реализации JSONDecoder/Encoder, KeyedDecodingContainer и KeyedEncodingContainer, связанных с методом swift_conformsToProtocolMaybeInstantiateSuperclasses.

Обходим касты стороной

После быстрого изучения кода мы нашли несколько узких мест, где происходит вызов метода swift_conformsToProtocolMaybeInstantiateSuperclasses.

// JSONDecoder.swift
func unwrap<T: Decodable>(...) throws -> T {
 if type == Date.self {
   return try self.unwrapDate(...) as! T
 }
 ...
 if T.self is _JSONStringDictionaryDecodableMarker.Type {
   return try self.unwrapDictionary(...)
 }
 ...
}

В строке 7 проверяется, является ли тип T словарем, где ключ — String, а значение реализует протокол Decodable. Аналогичное место встречается и в JSONEncoder-е.

// JSONEncoder.swift
func wrapGeneric<T: Encodable>(...) throws -> JSONEncoderValue? {   
 if let date = value as? Date {       
   return try self.wrap(date, for: additionalKey)   
 }   
 ...   
 else if let encodable = value as? _JSONStringDictionaryEncodableMarker {       
   return try self.wrap(encodable as! [String:Encodable], for: additionalKey)   
 } else if let array = value as? _JSONDirectArrayEncodable {
   ...       
 }   
 ...
}

Проверка в строке 7 очень похожа на предыдущую: проверяем, является ли тип T словарем, где ключ — String, а значение реализует протокол Encodable

Вторая проверка в строке 9 иного рода. Там мы проверяем, является ли наш тип T массивом из примитивов, для которых мы можем значительно ускорить процесс сериализации.

Перед тем как бросаться оптимизировать, стоит внимательно изучить, а зачем эти протоколы вообще нужны. Я тщетно потратил много времени и сил, пытаясь придумать, как полностью отказаться от этих кастов. Не повторяйте моих ошибок.

Как оказалось, _JSONStringDictionaryDecodableMarker и _JSONStringDictionaryEncodableMarker используются, чтобы при наличии нестандартной keyDecodingStrategy/keyEncodingStrategy не применять это преобразование к ключам в словарях.

Значит, мы можем делать каст к _JSONStringDictionaryDecodableMarker, только если keyDecodingStrategy != .useDefaultKeys. С помощью такого трюка мы сможем обойти вызов swift_conformsToProtocolMaybeInstantiateSuperclasses в подавляющем большинстве случаев.

Аналогично поступим с функцией wrapGeneric в JSONEncoder: проверяем на соответствие протоколу _JSONStringDictionaryEncodableMarker, только если keyEncodingStrategy != .useDefaultKeys.

C _JSONDirectArrayEncodable все сложнее. Просто убрать его нельзя, так как с этой оптимизацией сериализация больших массивов из примитивных типов работает быстрее. Например, при использовании _JSONDirectArrayEncodable массив из 100 тысяч UInt8 сериализуется в 8 раз быстрее.

Первой была идея сделать отдельную опцию в настройках JSONEncoder, с помощью которой можно включать или выключать оптимизацию, так как _JSONDirectArrayEncodable позитивно влияет на скорость сериализации массива из примитивов, но негативно влияет на любые другие юзкейсы. Для реализации было необходимо расширять публичное API JSONEncoder-а, делать настройку JSONEncoder сложнее для получения оптимальной производительности, поэтому решили найти другой путь.

Следующая идея намного интереснее и проще предыдущей. Давайте пристально взглянем, как массив реализует протокол _JSONDirectArrayEncodable:

extension Array : _JSONDirectArrayEncodable where Element: _JSONSimpleValueArrayElement

Внутри Foundation объявлен приватный протокол _JSONSimpleValueArrayElement, который реализуют всего 15 типов: Int, Int8, Int16, Int32, Int64, Int128, UInt, UInt8, UInt16, UInt32, UInt64, UInt128, String, Float, Double. А другие типы вне Foundation его реализовать не могут, так как он приватный. Значит, есть всего 15 типов, которые реализуют протокол _JSONDirectArrayEncodable: [Int], [Int8], [Int16], [Int32], [Int64], [Int128], [UInt], [UInt8], [UInt16], [UInt32], [UInt64], [UInt128], [String], [Float], [Double].

Вместо каста к протоколу мы можем проверить, является ли наш тип T одним из этих 15 типов! И тем самым значительно снизить стоимость проверки на _JSONDirectArrayEncodable.

Важно, что проверку на равенство типов лучше делать используя вспомогательную функцию specializingCast:

@inline(__always)
internal func specializingCast<Input, Output>( value: Input, to type: Output.Type) -> Output? {
  guard Input.self == Output.self else { return nil }
  return _identityCast(value, to: type)
}

За счет проверки на равенство типов и быстрой реализации _identityCast, которая под капотом совершает операцию, очень похожую на reinterpret_cast в C++, мы получаем значительный прирост в быстродействии по сравнению с оператором is/as.

Вот как изменился код JSONDecoder:

// JSONDecoder.swift
func unwrap<T: Decodable>(...) throws -> T {
if type == Date.self {
  return try self.unwrapDate(...) as! T
}
...
// Не делаем каст, если используется стандартная keyDecodingStrategy
if !options.keyDecodingStrategy.isDefault, T.self is _JSONStringDictionaryDecodableMarker.Type {
  return try self.unwrapDictionary(...)
}
...
}
...
fileprivate extension JSONDecoder.KeyDecodingStrategy {
   var isDefault: Bool {
       switch self {
       case .useDefaultKeys: true
       default: false
       }
   }
}

А вот как изменился код JSONEncoder:

// JSONEncoder.swift
func wrapGeneric<T: Encodable>(...) throws -> JSONEncoderValue? {   
 if let date = value as? Date {       
   return try self.wrap(date, for: additionalKey)   
 }   
 ...   
 else if !options.keyEncodingStrategy.isDefault, let encodable = value as? _JSONStringDictionaryEncodableMarker {       
   return try self.wrap(encodable as! [String:Encodable], for: additionalKey)   
 } else if let array = _asDirectArrayEncodable(value) {
   ...       
 }   
 ...
}
...
func _asDirectArrayEncodable<T: Encodable>(_ value: T) -> _JSONDirectArrayEncodable? {
 return if let array = _specializingCast(value, to: [Int8].self) {
     array
 } else if let array = _specializingCast(value, to: [Int16].self) {
     array
 } else if let array = _specializingCast(value, to: [Int32].self) {
     array
 } else if let array = _specializingCast(value, to: [Int64].self) {
     array
 } else if let array = _specializingCast(value, to: [Int128].self) {
     array
 } else if let array = _specializingCast(value, to: [Int].self) {
     array
 } else if let array = _specializingCast(value, to: [UInt8].self) {
     array
 } else if let array = _specializingCast(value, to: [UInt16].self) {
     array
 } else if let array = _specializingCast(value, to: [UInt32].self) {
     array
 } else if let array = _specializingCast(value, to: [UInt64].self) {
     array
 } else if let array = _specializingCast(value, to: [UInt128].self) {
     array
 } else if let array = _specializingCast(value, to: [UInt].self) {
     array
 } else if let array = _specializingCast(value, to: [String].self) {
     array
 } else if let array = _specializingCast(value, to: [Float].self) {
     array
 } else if let array = _specializingCast(value, to: [Double].self) {
     array
 } else {
     nil
 }
}
...
fileprivate extension JSONEncoder.KeyEncodingStrategy {
 var isDefault: Bool {
   switch self {
   case .useDefaultKeys:
     return true
   case .custom, .convertToSnakeCase:
     return false
   }
 }
}

Оптимизация ускорила JSONDecoder и JSONEncoder в 2 раза! Результаты нашего A/B-эксперимента на 80 тысячах пользователей для JSONDecoder:

Квантиль

0.1

0.25

0.5

0.75

0.9

стандартный JSONDecoder

198 ms

282 ms

422 ms

667 ms

1017 ms

новый JSONDecoder

100 ms

133 ms

200 ms

322 ms

528 ms

Разница

↑49.5%

↑52.8%

↑52.6%

↑51.7%

↑48.1%

Результаты А/В-эксперимента на 80 тысячах пользователей для JSONEncoder:

Квантиль

0.1

0.25

0.5

0.75

0.9

стандартный JSONEncoder

59 ms

94 ms

159 ms

289 ms

547 ms

новый JSONEncoder

14 ms

30 ms

73 ms

135 ms

220 ms

Разница

↑76%

↑68%

↑54%

↑53.2%

↑59.8%


Но мы знаем, что можно сделать еще быстрее.

Уходим от оверхеда из-за generic-ов 

К сожалению, касты не единственная причина плохой производительности JSONDecoder/Encoder. Причиной следующего узкого места является не вызов какой-то тяжелой функции, как раньше, а само объявление типов:

public struct KeyedEncodingContainer<K: CodingKey>
public struct KeyedDecodingContainer<K: CodingKey>

Когда мы объявляем generic-тип, у generic-параметра есть условие, что он должен конформить какому-либо протоколу. Тогда у Swift Runtime появляется необходимость хранить protocol witness table для этого протокола внутри метаданных типа в GenericParameterVector. К сожалению, Swift далеко не всегда хочет инлайнить Protocol Witness Table в GenericParameterVector и вместо этого будет искать Protocol Witness Table в рантайме с помощью метода swift_conformsToProtocolMaybeInstantiateSuperclasses.

KeyedDecodingContainer создается внутри почти каждой функции init(from: Decoder), когда вызываем функции container(keyedBy:). А KeyedEncodingContainer — внутри функции encode(to: Encoder) при вызове container(keyedBy:)

Ситуацию усугубляет автоматическая генерация реализации протоколов Decodable/Encodable: для каждого класса или структуры генерируется свой enum CodingKeys, а для enum-ов может генерироваться сразу несколько enum-ов CodingKeys! Значит, при первом вызове encode(to:) или init(from:) у большинства из Codable-типов мы потратим много времени на вызов swift_conformsToProtocolMaybeInstantiateSuperclasses. А последующие вызовы будут намного быстрее, так как нужный protocol conformance descriptor уже лежит в кэше. 

Исключением является Codable-типы, которым не нужен свой CodingKeys. Например, enum-ы, наследуемые от String, Int или RawRepresentable-enum-ы. Такие типы используют singleValueContainer для декодинга/енкодинга, а типы SingleValueDecodingContainer/SingleValueEncodingContainer не имеют generic-параметров.

Можно мне возразить: «Но, Кристиан, может, все не так плохо, медленным будет только первый вызов!» Это хорошее замечание. Но именно на старте приложения или при первом открытии экранов скорость парсинга будет значительно медленнее, чем могла бы быть. И это точно повлияет на впечатление, которое оставит приложение.

Избавиться от оверхеда из-за type-generic-constraint-ов на уровне стандартной библиотеки Swift, к сожалению, можно только сломав обратную совместимость. Например, можно убрать type-generic-constraint в extension:

public struct KeyedDecodingContainer<K>
{
 internal var _box: _KeyedDecodingContainerBase
 public init<Container: KeyedDecodingContainerProtocol>(
   _ container: Container
 ) where Container.Key == Key {
   _box = _KeyedDecodingContainerBox(container)
 }
}


extension KeyedDecodingContainer: KeyedDecodingContainerProtocol where K: CodingKey {
 public typealias Key = K
 public var codingPath: [any CodingKey] {
   return _box.codingPath
 }
 // continue to conform to KeyedDecodingContainerProtocol protocol
 ...
}

Аналогично можно поступить с KeyedEncodingContainer. К сожалению, это слишком радикальное решение, и мы не сможем реализовать его ни у себя в проекте, ни в стандартной библиотеке Swift-а. Даже такое решение сможет исправить лишь проблему производительности. А у CodingKey-ев есть еще два важных недостатка:

  • Каждый CodingKey увеличивает количество protocol conformance descriptor-ов на 5, так как любой CodingKey реализуют протоколы: Hashable, Equatable, CustomStringConvertible, CustomDebugStringConvertible, CodingKey. А увеличение количества protocol conformance descriptor-ов ухудшает производительность метода swift_conformsToProtocolMaybeInstantiateSuperclasses!  

  • Каждый CodingKey увеличивает размер приложения примерно на 1,8 КБ из-за генерации кода для реализации этих 5 протоколов.

В нашем проекте около 6000 CodingKey-ев, а значит, можно сократить размер приложения минимум на 12 МБ. А еще можно улучшить производительность метода swift_conformsToProtocolMaybeInstantiateSuperclasses на 15%, если отказаться от CodingKey-ев вообще!

Так родилось наше обходное решение. Раз полностью избавиться от вызова swift_conformsToProtocolMaybeInstantiateSuperclasses мы не можем, то воспользуемся фактом, что долгим будет только первый вызов для пары (тип, протокол). 

Значит, если сделать универсальный CodingKey для всех Codable-моделей, то после первого использования универсального CodingKey мы избавимся от оверхеда метода swift_conformsToProtocolMaybeInstantiateSuperclasses.

В качестве универсального CodingKey-я можно рассмотреть реализацию:

struct AnyCodingKey: CodingKey {
   let stringValue: String
   var intValue: Int? { nil }

   init?(stringValue: String) {
       self.stringValue = stringValue
   }

   init?(intValue: Int) {
       nil
   }
}

Но как измерить разницу в производительности без огромного количества доработок в основном проекте? Для этого мы решили написать свой бенчмарк.

Измеряем бенчмарком первый decoding/encoding

Мы разработали свой бенчмарк, чтобы сравнить производительность в четырех сценариях: стандартная реализация и стандартная реализация и String в качестве CodingKey, оптимизированная реализация и оптимизированная реализация и String в качестве CodingKey. В репозитории можно найти оптимизированные версии JSONDecoder/Encoder. 

Сгенерировали десять тысяч классов, они сгруппированы в группы по 4 класса:

public final class A1: Codable, Sendable {
   public let a: Int
   public let b: A2
   public let c: [A3]
   public let d: [String: A4]
}

public final class A2: Codable, Sendable {
   public let a: Int
   public let b: A3
   public let c: [A4]
}

public final class A3: Codable, Sendable {
   public let a: Int
   public let b: A4
}

public final class A4: Codable, Sendable {
   public let a: Int
}

Для измерения производительности декодинга мы взяли простой JSON-файл, размер которого всего 319 байт, и раздекодили его 2500 раз: сначала в класс A1, потом в A5 и так далее. Чтобы измерить производительность encoding-а, мы сгенерировали 2500 почти одинаковых вызовов конструкторов: от А1, А5 до А9997, и вызвали их, замерив время.

Так мы смогли измерить исходную производительность и сравнить ее с производительностью декодинга/енкодинга с быстрыми реализациями JSONDecoder/Encoder.

Мы сгенерировали десять тысяч Codable-моделей с кастомной реализацией init(from:) и encode(to:), чтобы замерить производительность с универсальным CodingKey. В бенчмарке мы использовали String вместо AnyCodingKey для простоты. 

Есть нюанс: когда мы сгенерим 10 тысяч таких Codable-моделей, где нет CodingKey-ев, количество protocol conformance descriptor-ов станет ~20 тысяч, вместо ~70 тысяч для моделей со стандартными CodingKeys. Так как производительность ��етода swift_conformsToProtocolMaybeInstantiateSuperclasses зависит от количества protocol conformance descriptor-ов, наши измерения могут быть искажены. Мы сгенерировали еще 10 тысяч CodingKey-ев, чтобы выровнять количество protocol conformance descriptor-ов.

Пример сгенерированного класса:

public final class A1: Codable, Sendable {
   public let a: Int
   public let b: A2
   public let c: [A3]
   public let d: [String: A4]

   public init(from decoder: Decoder) throws {
       let container = try decoder.container(keyedBy: String.self)
       self.a = try container.decode(Int.self, forKey: "a")
       self.b = try container.decode(A2.self, forKey: "b")
       self.c = try container.decode([A3].self, forKey: "c")
       self.d = try container.decode([String: A4].self, forKey: "d")
   }
   public func encode(to encoder: Encoder) throws {
       var container = encoder.container(keyedBy: String.self)
       try container.encode(a, forKey: "a")
       try container.encode(b, forKey: "b")
       try container.encode(c, forKey: "c")
       try container.encode(d, forKey: "d")
   }
   public enum CodingKeys: CodingKey {
       case a
       case b
       case c
       case d
   }
}

Результаты нашего бенчмарка для JSONDecoder-а:

квантиль

0.25

0.5

0.75

стандартный JSONDecoder

5.81 s

5.826 s

5.86 s

стандартный JSONDecoder + String в качестве CodingKey

3.24 s (↑44%)

3.26 s (↑44%)

3.29 s (↑43.9%)

оптимизированный JSONDecoder

2.64 s (↑55%)

2.65 s (↑55%)

2.66 s (↑54.6%)

оптимизированный JSONDecoder + String в качестве CodingKey

0.113 s (↑98%)

0.114 s (↑98%)

0.116 s (↑98%)

Результаты нашего бенчмарка для JSONEncoder-а:

квантиль

0.25

0.5

0.75

стандартный JSONEncoder

8.06 s

8.08 s

8.12 s

стандартный JSONEncoder + String в качестве CodingKey

5.49 s (↑32%)

5.52 s (↑32%)

5.55 s (↑32%)

оптимизированный JSONEncoder

2.67 s (↑67%)

2.68 s (↑67%)

2.69 s (↑67%)

оптимизированный JSONEncoder + String в качестве CodingKey

0.148 s (↑98.1%)

0.149 s (↑98.2%)

0.151 s (↑98.1%)

Наш бенчмарк хорошо иллюстрирует, насколько велики накладные расходы на вызовы метода swift_conformsToProtocolMaybeInstantiateSuperclasses. Конечно, в другом приложении цифры могут получиться другие. Главный вывод, который просматривается в результатах: за счет комбинации двух оптимизаций мы получаем прирост производительности на порядок.

Но почему Apple пропустили такое? 

Бенчмарк Apple

Мы были крайне удивлены, что бенчмарки для JSONDecoder/Encoder все-таки существуют. 

Как устроены бенчмарки Apple:

  • Для их написания Apple пользовались package-benchmark-ом. 

  • Для JSONDecoder запускается десериализация Data в одну и ту же модель.

  • Для JSONEncoder запускается сериализация декодированного файла обратно в Data.

  • Количество итераций — миллиард.

Почему на бенчмарках Apple все хорошо?

  • Миллиард итераций, но мы всегда попадаем в кэш метода swift_conformsToProtocolMaybeInstantiateSuperclasses у JSONDecoder-а.

  • До самих замеров мы предварительно раздекодили нужные нам данные.

  • Миллиард итераций, но только в первый раз мы попадем в кэш метода swift_conformsToProtocolMaybeInstantiateSuperclasses у JSONEncoder-а, и то не везде.

  • Поэтому на бенчмарках Apple не виден эффект от наших изменений.

Чтобы написать качественный бенчмарк, мало досконально понимать, как работает компонент. Нужно еще понимать, как работает язык программирования и его Runtime, чтобы не попасть впросак.

PR в Swift-Foundation

Я решил написать в Apple. На самом деле это не первый мой PR в Swift и в его стандартную библиотеку. В конце 2024 года я нашел неоптимальность в вычислении computed-property Error.localizedDescription: для получения типа ошибки использовалась тяжеловесная функция String(reflecting:), внутри которой метод swift_conformsToProtocolMaybeInstantiateSuperclasses вызывался 4 раза. 

Сначала я завел тред на forums.swift.org, потом issue в репозитории в надежде получить какую-то обратную связь перед созданием PR-а. Прождав почти месяц, решил сделать PR. В тот же день я получил ответ от одного из разработчиков и был приятно удивлен! Уже на следующий день PR был влит! А вскоре влили такие же изменения в соседние репозитории: swift-foundation и в сам swift.

Когда я собрал результаты A/B-теста с первой оптимизацией в JSONDecoder/Encoder-е, подтвердилось ускорение почти в 2 раза. Мы сразу задумались о создании PR в swift-foundation, чтобы исправить эту проблему. И, конечно, окрыленный скоростью влития моих первых PR-ов, верил, что и в этот раз удастся оперативно справиться с PR-ом.

Решено было начать с заведения треда на forums.swift.org, чтобы получить ответы от всего swift-community.

Для подготовки я собрал всю имеющуюся у нас статистику на тот момент, скриншоты профилирования, результаты бенчмарков. Коротко написал о дороговизне кастов, как считать количество protocol conformance descriptor-ов, почему существующий бенчмарк игнорировал этот негативный эффект, в итоге получился достаточно массивный пост.

В этот же день я получил фидбэк от разработчика, который занимается новым механизмом парсинга в Swift. Другие члены коммьюнити в комментариях к посту показали мне альтернативные реализации JSONDecoder: (ReerJSON, ZippyJSON). Оказалось, их производительность страдает из-за тех же проблем. Поэтому я повторил оптимизации в ReerJSON и ZippyJSON.

Параллельно я открыл issue в swift-foundation, где обсуждали оптимизации каста к _JSONDirectArrayEncodable. Там и родилась идея не просто убрать каст, а заменить его на проверку равенства типов.

А через несколько дней я открыл PR, где пришлось несколько раз пинговать maintainer-ов этого репозитория для запуска билдов и тестов на CI, так как у меня отсутствовали права на запуск.

Сам процесс принятия PR занял чуть более месяца. Конечно, я ожидал, что влить изменения получится сильно быстрее. 

А если вести отсчет от старта моего исследования производительности JSONDecoder/Encoder, то выйдет почти восемь месяцев. Этот тернистый путь я начал примерно в первых числах февраля 2025 года. За прошедшие 8 месяцев мы с командой прошли через многое: 

  • создание локального бенчмарка; 

  • провальные попытки неинвазивной интеграции для проведения A/B-теста;

  • тестовую интеграцию для замера улучшений;

  • написание и защиту 2 RFC; 

  • первую миграцию; 

  • подготовку инфраструктуры (danger-проверки);

  • написание документации и скрипта для автоматической миграции. 

Было очень интересно и увлекательно!

Текущее состояние миграции в нашем проекте

В конце июля этого года мы закончили миграцию на оптимизированные реализации JSONDecoder/Encoder в репозитории нашего банковского приложения для физлиц. Прежде чем начинать миграцию во всех iOS-репозиториях, мы решили провести A/B-тест, чтобы убедиться в эффективности наших изменений.

Мы разработали библиотеку, которая позволяет централизованно подменять реализацию JSONDecoder/Encoder-ов, чтобы избавить будущих нас от длительных миграций. Мы используем ее не только для оптимизаций, но и для сбора метрик.

Результаты A/B-теста нас порадовали, поэтому в сентябре мы начали форсировать другие команды мигрировать на наше решение:

  • Сделали danger-проверку на использование конструктора JSONDecoder/Encoder в библиотеках, которые интегрируются к нам в приложение.

  • Чтобы не сканировать файлы с кодом, было решено идти другим путем: смотреть на наличие символа конструктора JSONDecoder/Encoder в собранной библиотеке.

  • Найти эти символы можно с помощью утилиты nm: nm path/to/your/binary | grep -E 's10Foundation11JSON(Decoder|Encoder)CACycf[Cc]'.

  • Если мы нашли этот символ в интегрируемой библиотеке, мы даем ответственной команде 3 месяца на миграцию.

В конце ноября 2025 года больше половины команд, которые ранее использовали стандартные JSONDecoder/Encoder, использовали новый подход.

В начале сентября 2025 мы начали подбирать подход для устранения второго бутылочного горлышка — генерации минимум одного CodingKey для большинства Codable-типов в нашей кодовой базе.

До разработки решения мы хотели оценить масштаб проблемы и количество CodingKeys в нашем приложении. Оценку по кодовой базе в рамках нашего репозитория проводить бессмысленно: число CodingKey-ев сильно отличалось бы от реального, ведь у нас очень много 2nd-party-библиотек. Мы искали способ подсчитать количество CodingKey-ев в собранном исполняемом файле нашего приложения. И сделать это можно с помощью команды: nm path/to/your/binary | grep 'CodingKeys.*OMf' | wc -l

В нашем исполняемом файле оказалось около 6 тысяч CodingKey-ев. Если убрать все CodingKey-и, мы сможем сократить размер приложения минимум на 12 МБ и количество protocol conformance descriptor-ов на 30 тысяч, так как каждый CodingKey добавляет 5 protocol conformance descriptor-ов.

Далее мы сравнили производительность сериализации и десериализации более 50 сущностей с сгенерированным CodingKey и с универсальным. Замеры проводили на устройстве нашего QA-инженера. Сделали около 50 замеров с использованием оптимизированного JSONDecoder/Encoder: в среднем мы получили ускорение еще в 3—4 раза. В некоторых случаях ускорение доходило до 20 раз: это enum-ы примерно следующей структуры:

public indirect enum InsuranceShortPolicy: Equatable {
   case osago(InsuranceShortVehiclePolicy)
   case travel(InsuranceShortTravelPolicy)
   case health(InsuranceShortHealthPolicy)
}

public init(from decoder: Decoder) throws {
   let container = try decoder.container(keyedBy: CodingKeys.self)

   if let policy = try? container.decode(InsuranceShortVehiclePolicy.self, forKey: .osago) {
       self = .osago(policy)
   } else if let policy = try? container.decode(InsuranceShortTravelPolicy.self, forKey: .travel) {
       self = .travel(policy)
   } else if let policy = try? container.decode(InsuranceShortHealthPolicy.self, forKey: .health) {
       self = .health(policy)
   } else {
       let context = DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "")
       throw DecodingError.dataCorrupted(context)
   }
}

Функция init(from decoder: Decoder) работает медленно из-за обильного создания KeyedDecodingContainer с разными generic-параметрами, ведь у каждой из сущностей InsuranceShortVehiclePolicy, InsuranceShortTravelPolicy, InsuranceShortHealthPolicy свой собственный тип CodingKey. А при использовании универсального CodingKey мы избавляемся от оверхеда из-за type-generic-constraint-ов в KeyedDecodingContainer.

Самым оптимальным решением нам показался макрос. С его помощью можно сгенерировать реализацию init(from:) и encode(to:) с универсальным CodingKey-ем: в таком случае компилятор Swift не будет генерировать CodingKeys, так как не увидит в нем нужды. Все нужные методы реализованы, упоминаний CodingKeys нет.

После написания макроса и тестовой интеграции в проект мы осознали, что вручную заменять Codable на использования макроса — долго и чревато ошибками. Для облегчения переезда разработали скрипт с использованием библиотеки SwiftSyntax. Возможности скрипта:

  • Находить Decodable/Encodable-типы.

  • Если у типа нет функций init(from:) или encode(to:) — убирает конформанс Decodable/Encodable/Codable, убирает CodingKey и применяет макрос на структуре/классе/enum-е. Если название поля в JSON не совпадает с названием поля в сущности — применяется специальный макрос-маркер.

  • Если в типе реализуется init(from:) или encode(to:) вручную — заменяет все CodingKey-и на AnyCodingKey.

  • Если что-то не получилось — вставляет в код Xcode-Placeholder, который позволит разработчику заметить и исправить проблемное место.

  • Автоматически добавляет нужные import.

После тестирования скрипта на первопроходцах мы внедрили новое правило при добавлении новых библиотек в наш проект или при их обновлении: количество CodingKey не должно превышать 10. Если же в библиотеке больше 10 CodingKey-ев, на команду заводится задача с дедлайном в 3 месяца.

Казалось бы, что может пойти не так? Но уже через неделю стало понятно, что в библиотеках, где уже была произведена миграция на наш макрос, в новом коде разработчики часто забывают о необходимости использовать макрос. Необходимо научиться подсвечивать разработчикам проблему использования CodingKeys до момента интеграции в наш проект или поднятия версии в нашем проекте.

Для этого мы реализовали схожую проверку в общем пайплайне, который используется большинством разработчиков библиотек. Данная проверка существенно уменьшила количество «повторных» миграций на наш макрос.

В рамках миграции на универсальный CodingKey, на 30 ноября 2025, мы уже сократили количество CodingKey-ев на 800 штук, то есть уменьшили размер приложения почти на 1,5 МБ и сократили количество protocol conformance descriptor-ов на 4000.

Заключение

Опыт нашей команды в работе над производительностью показал, что оптимизации базовых технологий и компонентов намного ценнее точечных оптимизаций. Точечные оптимизации очень хрупкие: немного изменится граф инициализации, последовательность выполнения некоторых функций, добавится новый аргумент в конструктор — и все, точечная оптимизация уже не работает или неактуальна.

А такие базовые компоненты, как JSONDecoder/Encoder, используются в iOS-проектах повсеместно, и ускорение их работы дает ощутимое влияние на общую производительность приложений.

Мы очень рады, что благодаря внедрению наших наработок в swift-foundation многие iOS-приложения начнут работать быстрее после обновления на новую версию iOS. Если хочется еще большей производительности, можно взять нашу реализацию JSONDecoder/Encoder и использовать ее без ограничений по версии ОС. А еще можно воспользоваться нашим подходом по отказу от CodingKeys с помощью макроса.

В статье мы погрузились в «кишки» JSONDecoder/JSONEncoder, нашли узкие места в реализации, вспомнив про нюансы работы Swift Runtime, рассмотрели проблему с generic-ами в KeyedDecodingContainer и KeyedEncodingContainer. Проанализировав код JSONDecoder/JSONEncoder мы реализовали оптимизации, которые ускоряют сериализацию и десериализацию в 2 раза.

Сравнили влияние оптимизаций на производительность в нашем бенчмарке, а проанализировав бенчмарк Apple — осознали, почему Apple не знали о медленной работе JSONDecoder/Encoder.

Я поделился текущим состоянием внедрения оптимизаций в нашем проекте: как следим за разработчиками у нас в репозитории и в соседних, рассказал про упрощение переезда на наш макрос, который генерирует реализацию Codable без CodingKeys

Наш опыт внесения изменений в Swift оказался позитивным: сделали JSONDecoder/Encoder быстрее не только в нашем приложении, а сразу во всех! Предположительно наши оптимизации выйдут в релизе Foundation для iOS 26.3.

Зову поделиться в комментариях, если у кого-то есть похожий опыт или остались вопросы 🤗