Chez DoorDash, nous faisons des millions de prédictions chaque seconde pour alimenter les applications d'apprentissage automatique afin d'améliorer nos domaines de recherche, de recommandation, de logistique et de fraude, et la mise à l'échelle de ces systèmes complexes avec notre magasin de fonctionnalités est continuellement un défi. Pour faire des prédictions, nous utilisons un microservice appelé Sibyl Prediction Service (SPS) écrit en Kotlin et déployé dans des pods Kubernetes avec équilibrage de charge pour servir toutes nos demandes de prédiction. Ces demandes prennent généralement des caractéristiques en entrée et produisent une valeur de retour qui conduit à une décision de produit. Une valeur de caractéristique typique capturera le nombre d'articles dans la commande d'une personne(numérique), sera un identifiant du marché dans lequel elle se trouve(catégorique), ou sera une représentation d'un élément de menu qu'elle commande (intégration). Nous pouvons également représenter des types de données plus complexes (tels que des images) sous forme de vecteurs.
Ces valeurs de caractéristiques sont fréquemment chargées à partir de notre magasin de caractéristiques, qui est alimenté par un ensemble de grands clusters Redis (magasins de clés-valeurs en mémoire). Nous utilisons plusieurs To de mémoire dans nos clusters Redis, de sorte que ce service est une source importante de coûts et de maintenance. En outre, les demandes de prédiction sont effectuées en ligne et doivent être servies en quelques millisecondes, avec des exigences strictes en matière d'intégrité.
Nous avons étudié l'idée d'ajouter une couche de mise en cache au niveau du microservice afin d'améliorer l'efficacité des requêtes et d'alléger la charge sur Redis, en espérant que cela rende notre système plus évolutif. Nous avons instrumenté SPS avec des journaux pour comprendre les schémas de requête. Après notre analyse initiale, nous avons constaté qu'une grande partie des requêtes sont répétées et gaspillées, ce qui plaide en faveur de l'efficacité de la couche de mise en cache en cours de processus que nous avons proposée.
Notre magasin de fonctionnalités comporte certaines optimisations, telles que l'encodage, la compression et la mise en lots. Nous les expliquerons en détail afin que vous puissiez mieux comprendre la configuration de notre expérience. Une fois que nous avons décidé des étapes de l'instrumentation, nous avons étudié différentes bibliothèques de mise en cache afin d'obtenir les meilleures performances du système.
Pour maintenir la stabilité du système, nous avons déployé la solution de mise en cache par phases, avec des étapes de vérification rigoureuses en cours de route. Après quelques itérations, nous avons constaté que les résultats réels correspondaient étroitement à nos simulations, avec un taux de réussite du cache de plus de 70 %.
Comment nous utilisons les caractéristiques de ML pour faire des prédictions dans Sibyl Prediction Service aujourd'hui
Comment les fonctionnalités se retrouvent-elles dans le magasin de fonctionnalités ? À l'aide d'une méthode centralisée, nous extrayons les données de base de notre base de données et les téléchargeons dans un format spécifique vers notre magasin de fonctionnalités.
Pour les fonctionnalités par lots (telles que les fonctionnalités statistiques à long terme), cela se produit sur une base périodique, par exemple sur une base quotidienne orchestrée par notre intégration interne appelée Fabricator. Les fonctionnalités en temps réel, quant à elles, sont téléchargées en continu à l'aide de files d'attente Kafka orchestrées par Riviera, notre cadre d'ingénierie des fonctionnalités.
Dans les deux cas, les caractéristiques sont stockées dans un magasin Redis dans un format spécifique, qui fournira un contexte important pour comprendre les principes de conception derrière notre solution de mise en cache des caractéristiques.
En interne, nous nommons les caractéristiques en utilisant une convention utilisée pour encoder le type d'agrégation, une brève description et le type de caractéristique. Nous hachons ensuite ces noms de caractéristiques à l'aide de xxHash afin de réduire les coûts de stockage répétitifs des longues chaînes de caractères :
xxHash32(daf_cs_p6m_consumer2vec_emb) = 3842923820
Nous associons ensuite chacune de ces caractéristiques aux entity_ids, qui sont les identifiants internes des consommateurs, des Dashers (livreurs), des commerçants, des livraisons, etc. Pour pouvoir récupérer plusieurs valeurs de caractéristiques pour un ID d'entité donné en une seule requête, nous créons des hashmaps dans Redis avec pour clé l'ID d'entité, comme suit :
HSET entity_id xxHash32(feature_name) feature_value
Lors d'une demande de prédiction, nous récupérons une ou plusieurs valeurs de caractéristiques pour tous les ID d'entité concernés :
HMGET entity_id1 xxHash32(feature_name1) xxHash32(feature_name2) …
HMGET entity_id2 xxHash32(feature_name3) …
…
qui renvoie la correspondance
{
entity_id1: {feature_name1: feature_value1,
feature_name2: feature_value2, …}
entity_id2: {feature_name3: feature_value3, …},
…
}
Certaines valeurs de caractéristiques non triviales (telles que les listes) peuvent être très longues, et nous les sérialisons alors à l'aide de tampons de protocole. L'ensemble du trafic envoyé au magasin de caractéristiques est ensuite compressé à l'aide de la méthode de compression Snappy.
Les principes de conception qui sous-tendent notre magasin de fonctionnalités peuvent être consultés plus en détail dans un article précédent sur notre magasin de fonctionnalités.
Restez informé grâce aux mises à jour hebdomadaires
Abonnez-vous à notre blog d'ingénierie pour recevoir régulièrement des informations sur les projets les plus intéressants sur lesquels notre équipe travaille.
Veuillez saisir une adresse électronique valide.
Merci de vous être abonné !
Le cycle de vie des demandes d'enregistrement de fonctionnalités
Lors d'une demande de prédiction, SPS peut recevoir plusieurs requêtes de prédiction et, pour chacune d'entre elles, il prend en entrée un ensemble de noms de caractéristiques et d'identifiants d'entités correspondants présentant un intérêt. Certaines de ces caractéristiques sont fournies par le client en amont puisqu'il dispose du contexte (par exemple, le terme de la requête de recherche actuelle fourni par notre client), mais la plupart des caractéristiques doivent être extraites du magasin de caractéristiques. Les caractéristiques stockées sont généralement des caractéristiques statistiques sur une période de temps et peuvent être agrégées. Par exemple, la taille moyenne d'une commande passée auprès d'un commerçant au cours des sept derniers jours.
Lors de la demande de prédiction, les valeurs des caractéristiques sont renseignées dans l'ordre suivant :
- Caractéristiques transmises par la demande
- Caractéristiques extraites de Redis
- Valeur par défaut de l'élément, si l'élément n'est pas dans Redis
Nous utilisons un ensemble de clusters Redis partagés, où nous partitionnons les données en fonction des cas d'utilisation. Par exemple, nous avons une grappe séparée pour les cas d'utilisation de la recherche et de DeepRed (le système au centre de notre plateforme logistique du dernier kilomètre). Cela nous permet de faire évoluer le cluster de stockage pour chaque cas d'utilisation de manière indépendante. Nous effectuons des requêtes par lots vers Redis pour chaque identifiant d'entité, qui comporte généralement entre 10 et 100 noms de caractéristiques. Nous récupérons des dizaines de millions de paires clé/valeur de nos clusters Redis en moyenne par seconde.
Le problème d'évolutivité de la boutique de fonctionnalités
Notre magasin de fonctionnalités est une base de données massive et très coûteuse qui doit prendre en charge les milliards de requêtes que nous faisons chaque jour pour récupérer les fonctionnalités ML. Nous stockons des fonctionnalités pour les clients DoorDash, les commerçants, les Dashers et les livraisons. Ces fonctionnalités peuvent être des fonctionnalités par lot ou en temps réel et représentent des milliards de lignes dans notre magasin de fonctionnalités.
Notre principale méthode de stockage des données est Redis, qui répond bien à nos besoins car nous avons besoin de prédictions à faible latence. Ces prédictions doivent intégrer des centaines de valeurs de caractéristiques pour chaque prédiction. Dans le même temps, ce cluster doit être hautement disponible pour nos demandes de prédiction et nous utilisons donc un grand nombre d'instances pour équilibrer la charge. Redis étant un magasin en mémoire, nous devons héberger nos données sur des serveurs dotés d'une grande quantité de mémoire. Le fait d'avoir des serveurs avec beaucoup de mémoire fait augmenter nos coûts de calcul.
La rentabilité est un aspect important de toute plateforme, et la plateforme de prédiction de DoorDash n'est pas différente. Le magasin de fonctionnalités contribue largement à nos coûts, c'est pourquoi notre équipe s'est attachée à résoudre ce problème d'évolutivité. À cette fin, nous avons exploré la possibilité d'utiliser d'autres systèmes de stockage (qui pourraient être plus lents mais plus rentables) pour nos fonctionnalités et explorer la mise en cache. Nous avons émis l'hypothèse que la plupart des requêtes adressées à notre magasin de fonctionnalités sont répétées et constituent donc un gaspillage, ce qui fait de la mise en cache une solution intéressante.
En mettant en place la mise en cache, nous espérions obtenir une amélioration significative :
- Latence : le temps nécessaire pour effectuer des prédictions. Des prédictions plus rapides peuvent débloquer davantage de cas d'utilisation et conduire à une meilleure expérience pour l'utilisateur final.
- Fiabilité : L'exécution régulière des prédictions dans les délais impartis signifie que nos prédictions sont de meilleure qualité puisqu'elles reviennent moins souvent à la ligne de base.
- Évolutivité : Au fur et à mesure que l'échelle de DoorDash augmente et que nous utilisons l'apprentissage automatique pour davantage de cas d'utilisation, nous pouvons faire des prédictions d'une manière plus rentable.
En fin de compte, même si nous ne réalisons pas immédiatement les gains susmentionnés, la mise en cache est une voie très prometteuse pour nous car elle permet de débloquer des backends de stockage qui sont plus lents mais plus rentables que Redis.
Comment la mise en cache peut nous aider à améliorer l'évolutivité et la fiabilité des prédictions en ligne
Avant de nous lancer dans un grand projet de migration, nous voulions rassembler plus d'informations pour estimer le retour sur investissement de notre idée. La mise en place d'un environnement de test fonctionnel en production représentait un investissement plus important et comportait le risque d'avoir un impact négatif sur le trafic de production, c'est pourquoi nous avons commencé par valider notre idée en utilisant la simulation.
Notre idée principale était de capturer le trafic réseau de production en direct provenant de notre microservice de prédiction vers notre cluster Redis. Nous avons instrumenté SPS de manière non intrusive pour avoir une idée des requêtes envoyées à Redis. Nous avons utilisé l'outil bien connu pcap pour capturer des paquets réseau et les enregistrer dans un fichier, puis nous avons réorganisé ces paquets dans le bon ordre à l'aide d'un outil appelé tcpflow.
Ces paquets TCP se présentaient comme suit :
HMGET st_1426727 2496559762 1170383926 3383287760 1479457783 40892719 60829695 2912304797 1843971484
Dans cet exemple, HMGET demandera huit paires différentes de valeurs d'entités à partir de la table de hachage correspondant à l'identifiant d'entité st_1426727. Les clés sont des valeurs xxhash'ed de noms d'entités au format entier 32 bits.
Une fois que nous avons eu un échantillon suffisamment important de ces commandes HMGET, nous avons exécuté un script Python personnalisé pour analyser les demandes et simuler divers scénarios de mise en cache dans le but d'estimer le taux de réussite de la mise en cache.
import sys
from functools import lru_cache
hits = misses = 0
cache = set()
@lru_cache(maxsize=100000)
def lru_get(x):
pass
def hit_rate(w):
if not w:
return 0.0
return sum(w) / len(w) * 100.0
with open(sys.argv[1], encoding="utf8") as f:
for x in f:
x = x.strip()
parts = x.split()
if not parts:
continue
if (parts[0] != "HMGET"):
continue
for i in range(2, len(parts)):
key = (parts[0], parts[1], parts[i])
lru_get(key)
if key in cache:
hits += 1
else:
cache.add(key)
misses += 1
lru_info = lru_get.cache_info()
lru_hitrate = lru_info.hits / (lru_info.hits + lru_info.misses) * 100.0
print("Infinite memory: {hits}, Misses: {misses}, Cumulative Hit Rate: {hits/(hits+misses)*100.0}")
print(f"LRU with size {LRU_SIZE}: {lru_info.hits}, Misses: {lru_info.misses}, Cumulative Hit Rate: {lru_hitrate}")
Grâce à ce cadre, nous avons pu effectuer diverses simulations hors ligne pour déterminer le comportement du cache.
Cette expérience a permis d'obtenir des informations précieuses. Dans nos benchmarks, nous avons vu que nous pouvions atteindre un taux de réussite de près de 70 % avec une capacité de cache de 1 000 000. Nous avons également découvert que le cache est saturé après 15 minutes de trafic de production.
En résumé, notre méthodologie de simulation est la suivante :
- Exécutez tcpdump sur les hôtes de production pour collecter le trafic sortant vers Redis pendant 15 minutes.
- Analyse les données des requêtes Redis pour obtenir la liste des clés récupérées.
- Simuler la mise en cache en examinant les clés répétées.
- Tracer des graphiques montrant les résultats de la mise en cache au fil du temps.
Comment nous avons mis en œuvre et mesuré les effets de la mise en cache pod-locale sur les performances de prédiction
En tant que première couche de mise en cache, nous avons entrepris d'implémenter un cache local en mémoire pour notre microservice SPS. L'idée était d'implémenter la logique du cache autour de la logique existante de récupération des caractéristiques - en renvoyant rapidement une valeur stockée dans le cache local.
Étant donné que nous traitons des milliards de valeurs de caractéristiques distinctes via SPS, nous ne pouvons pas nous permettre de stocker toutes ces valeurs dans chacun de nos pods. Nous devons limiter la taille de notre cache et, lorsqu'il est plein, expulser certaines valeurs (de préférence celles dont nous n'avons pas besoin). Par conséquent, nous devions trouver un schéma d'éviction du cache. Il existe de nombreux schémas d'invalidation de cache différents - pour notre cas d'utilisation, nous avons choisi un schéma d'invalidation LRU.
SPS est implémenté en Kotlin et nous avons donc cherché un moyen d'intégrer un cache LRU à notre microservice. Puisque SPS est une application multithread qui sert un énorme débit de requêtes, nous avions besoin d'une implémentation qui puisse fonctionner avec une concurrence élevée. Par conséquent, l'implémentation du cache devait être thread-safe. Il existe différentes stratégies pour atteindre la sécurité des threads, mais les implémentations utilisent généralement des verrous en lecture-écriture.
Une autre considération était l'observabilité : Nous devions savoir comment notre cache fonctionne dans un environnement réel et s'il correspond à nos attentes définies dans les expériences hors ligne. Chez DoorDash, nous utilisons Prometheus pour enregistrer les mesures, qui peuvent être visualisées sur des graphiques Grafana. Pour le cache LRU, nous voulions enregistrer la latence, le taux de réussite, la taille du cache, l'utilisation de la mémoire, le nombre de requêtes et l'exactitude du cache.
Solutions efficaces de mise en cache en Kotlin
Nous avons mis en place une interface de base pour notre cache en Kotlin :
interface Cache<K, V> {
// Returns the value associated with the key, or null if the value is not in the cache.
operator fun get(key: K): V?
// Inserts a key/value pair into the cache, overwriting any existing values
fun put(key: K, value: V)
// Deletes a specific key in the cache
fun delete(key: K)
// Deletes all keys in the cache
fun clear()
// Returns the estimated size of the cache
fun size(): Int
}
Une implémentation basique, inefficace et non limitée d'un cache pourrait ressembler à ce qui suit :
class UnboundedCache<K, V> : Cache<K, V> {
val store: MutableMap<K, V> = Collections.synchronizedMap(mutableMapOf())
override fun get(key: K): V? {
return store[key]
}
override fun put(key: K, value: V) {
assert(value != null)
store[key] = value
}
override fun delete(key: K) {
store.remove(key)
}
override fun clear() {
store.clear()
}
override fun size(): Int {
return store.size
}
}
Nous pouvons ajouter la mise en cache LRU à la classe en utilisant LinkedHashMap avec un removeEldestEntry personnalisé :
class LRUCache<K, V> (private val name: String, private val capacity: Int, private val loadFactor: Float = 0.75f) : Cache<K, V> {
private var store: MutableMap<K, V>
override fun size(): Int {
return store.size
}
init {
assert(capacity > 0)
store = object : LinkedHashMap<K, V>(capacity, loadFactor, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, V>): Boolean {
return size() > capacity
}
}
store = Collections.synchronizedMap(store)
}
override fun get(key: K): V? {
val value = store[key]
return value
}
override fun put(key: K, value: V) {
assert(value != null)
store[key] = value
}
override fun delete(key: K) {
store.remove(key)
}
override fun clear() {
store.clear()
}
}
Dans le code ci-dessus, la clause Collections.synchronizedMap(store) rend la classe thread-safe. Cependant, elle ne fonctionnera pas bien dans un environnement à forte concurrence, car nous verrouillons l'ensemble de la liste chaînée interne à chaque consultation et insertion. Par conséquent, ce code est purement illustratif et ne doit pas être utilisé dans une application de production.
Pour les cas d'utilisation en production, nous avons expérimenté plusieurs bibliothèques open-source et nous avons trouvé que la bibliothèque Caffeine (écrite en Java) était la plus efficace. Elle fournit une API propre et est très performante. Elle dispose d'excellentes références par rapport à d'autres bibliothèques open-source.
Intégration de la bibliothèque de caches avec SPS
Une fois la bibliothèque de cache mise en place, l'étape suivante consistait à modifier la logique de récupération des caractéristiques de SPS. L'idée était d'intercepter l'appel de recherche de caractéristiques et de vérifier le cache pour les valeurs de caractéristiques avant d'envoyer les requêtes à Redis. Nous avons ensuite récupéré les valeurs manquantes dans Redis et rempli notre cache avant de les renvoyer à l'appelant.
Alors que la majorité des valeurs des caractéristiques restent inchangées sur de courtes périodes, certaines caractéristiques en temps réel sont mises à jour plus fréquemment. Nous avons dû mettre en œuvre une stratégie d'éviction du cache pour maintenir l'intégrité de la prédiction. Pour les caractéristiques en lot, nous marquons les caractéristiques qui sont téléchargées dans Redis par leur heure de téléchargement et nous interrogeons périodiquement cette information dans SPS. Lorsqu'une caractéristique est téléchargée, nous expulsons toutes les valeurs de cette caractéristique. Ce processus d'éviction peut s'avérer fastidieux dans certains cas, mais nous avons constaté qu'il s'agissait d'un bon compromis pour maintenir l'intégrité.
Déployer en toute sécurité l'abstraction de mise en cache en direct dans un environnement critique pour l'entreprise
SPS étant un service critique, nous avons dû veiller tout particulièrement à ce que l'introduction de la complexité par la mise en cache n'interfère pas avec les demandes de production régulières. Nous avons dû faire très attention à ne pas augmenter la latence ou à ne pas utiliser des fonctionnalités incorrectes lors de l'élaboration des prédictions, car une augmentation de la charge peut entraîner une dégradation sévère de l'expérience utilisateur. C'est pourquoi nous avons minutieusement instrumenté notre service à l'aide de mesures et avons utilisé un processus de déploiement à blanc. En interne, nous suivons diverses mesures telles que la disponibilité, la latence et la précision des prédictions, que nous avons complétées par des caractéristiques de cache telles que le taux de réussite, la taille du cache et l'exactitude.
Lors de la première étape de l'expérience de mise en cache, nous n'avons marqué qu'un certain nombre de fonctionnalités comme pouvant être mises en cache. Cette liste nous a permis de déployer la mise en cache fonctionnalité par fonctionnalité et de ne pas avoir à nous préoccuper des fonctionnalités en temps réel dans un premier temps.
Nous avons ensuite ajouté un chemin de code parallèle aux requêtes Redis régulières qui simule la population et la récupération du cache et compare également le résultat final avec ce que la requête Redis de production produirait. Ce processus parallèle a été exécuté dans un thread asynchrone afin de ne pas causer de surcharge de latence dans le flux critique des requêtes. Dans notre environnement de production, nous avons constaté que Caffeine surpassait largement l'implémentation LinkedHashMap de Java et Guava, conformément à nos précédents benchmarks.
Au cours de ce processus d'essai, nous avons observé comment la taille du cache augmentait au fur et à mesure que nous recevions des demandes. La marche à blanc a également permis de déterminer le taux de réussite du cache auquel nous pouvions nous attendre. Au départ, le taux de réussite du cache était très faible en raison de divers bogues de mise en œuvre, de sorte que nos précautions en matière de déploiement ont déjà porté leurs fruits.
Nous avons comparé les valeurs attendues et les valeurs mises en cache sur une période plus longue afin de nous assurer que les valeurs renvoyées étaient conformes aux attentes. Ces vérifications ont été extrêmement utiles pour identifier les lacunes dans la logique de rafraîchissement des valeurs des caractéristiques.
Lorsque nous avons eu la certitude de pouvoir servir les requêtes mises en cache en direct, nous avons continué à contrôler le pourcentage de requêtes envoyées au cache et à limiter la taille du cache en mémoire. Nous avons suivi ce processus afin de ne pas surcharger l'environnement de production.
Après cette série de tests, nous avons lancé la mise en cache en direct et observé une amélioration de la latence et de la fiabilité. Il est également intéressant de noter que les caractéristiques du taux de réussite de la mémoire cache correspondent étroitement à ce que nous avons observé dans notre simulation.
Améliorer encore les performances globales de la recherche de caractéristiques dans les SPS
La mise en œuvre du cache local est la première étape d'une stratégie globale de mise en cache. Pour maximiser les performances et l'exactitude de la mise en cache, nous prévoyons d'apporter des améliorations supplémentaires afin de remédier à certaines inefficacités :
Problème de démarrage à froid : dans un environnement de production, les processus de microservices sont souvent redémarrés. Cela peut être dû à une allocation de ressources ou à un déploiement de service ordinaire. Lorsque le processus redémarre, il démarre avec un cache vide, ce qui peut gravement affecter les performances. Le shadowing du trafic de production vers un processus nouvellement démarré et le "réchauffement" de son cache peuvent résoudre le problème du démarrage à froid.
Frais généraux de désérialisation : Nous décompressons et désérialisons sans cesse les réponses brutes de Redis dans notre cache. Bien que le coût soit généralement faible, il peut tout de même s'accumuler, en particulier pour les objets volumineux tels que les embeddings. Nous pouvons modifier notre flux de travail pour stocker les objets désérialisés en mémoire :
Le partage du cache : Une autre optimisation potentielle consiste à améliorer le taux de réussite du cache en répartissant les caches au lieu de stocker les données indépendamment sur chaque module, sous forme de répliques. Une heuristique utile peut consister à répartir les demandes en fonction d'un identifiant spécifique à l'application. L'intuition est que les demandes similaires réutiliseront massivement les mêmes fonctionnalités. Par exemple, nous pouvons répartir les demandes de prédiction de l'heure d'arrivée estimée (ETA) en fonction de l'emplacement du commerçant et nous attendre à ce qu'un grand nombre des caractéristiques de ce commerçant soient probablement présentes dans le cache associé à ce commerçant, par rapport à un cache générique. Cette méthode, si elle est correctement mise en œuvre, peut effectivement multiplier la capacité du cache par le nombre de pods dont nous disposons.
Caractéristiques en temps réel : Les fonctionnalités en temps réel requièrent une attention particulière, car elles ne sont pas téléchargées en un seul lot, mais plutôt progressivement au fil du temps. Pour suivre les mises à jour des fonctionnalités en temps réel, nous pouvons demander à Riviera d'écrire les fonctionnalités mises à jour à la fois dans notre magasin de fonctionnalités et dans une file d'attente Kafka distincte. Ainsi, SPS peut lire cette file d'attente et mettre à jour son cache.
Conclusion
Chez DoorDash, nous avons déployé avec succès une couche de mise en cache pour notre infrastructure gigascale de prédiction et de stockage de fonctionnalités. Les méthodes de mise en cache ont été déployées avec succès dans des environnements de bas niveau (tels que les CPU) et de haut niveau (demande de réseau) pour une variété d'applications. Le concept est simple, mais sa mise en œuvre nécessite une connaissance du domaine et de l'expérimentation. Nous avons démontré qu'avec les bonnes abstractions et les bons repères, il peut être déployé progressivement et en toute sécurité dans un environnement de production à grande échelle.
Les Feature Stores sont couramment utilisés dans les opérations d'apprentissage automatique et de nombreuses équipes sont désormais confrontées à des problèmes d'évolutivité. La mise en cache est un moyen efficace d'améliorer l'évolutivité, la latence et la fiabilité tout en maintenant l'intégrité. En ajoutant des couches de mise en cache, les données de référence peuvent être stockées à un niveau plus rentable et plus évolutif sans sacrifier la latence.
Remerciements
Je tiens à remercier tout particulièrement
- Brian Seo pour avoir ajouté le marquage du statut de téléchargement du fabricant,
- Kunal Shah pour avoir conduit le développement de Riviera et de Fabricator,
- Arbaz Khan pour le développement de notre magasin de fonctionnalités et pour ses conseils en matière de SPS.
- Hien Luu pour ses conseils généraux et son soutien dans le cadre de notre initiative de mise en cache des fonctionnalités.