Acelerar la capacitación de modelos de IA/ML con operadores personalizados: Parte 4
En esta publicación continuamos nuestra exploración de las oportunidades para la optimización del tiempo de ejecución de cargas de trabajo de aprendizaje automático (ML) a través del desarrollo de operadores personalizados. En esta ocasión nos centramos en las herramientas proporcionadas por el SDK de neuronas de AWS para desarrollar y ejecutar nuevos kernels en amazon.com/machine-learning/trainium/” rel=”noopener ugc nofollow” target=”_blank”>Capacitación en AWS y amazon.com/machine-learning/inferentia/” rel=”noopener ugc nofollow” target=”_blank”>AWS Inferencia. Con el rápido desarrollo de los componentes del modelo de bajo nivel (por ejemplo, capas de atención) que impulsa la revolución de la IA, la programabilidad de los aceleradores utilizados para entrenar y ejecutar modelos de ML es crucial. Los chips de IA dedicados, en particular, deben ofrecer una alternativa digna a los marcos de desarrollo de GPU de propósito general (GPGPU), ampliamente utilizados y de gran impacto, como CUDA y Tritón.
En publicaciones anteriores (por ejemplo, aquí y aquí) exploramos la oportunidad de crear y ejecutar modelos de aprendizaje automático en chips de IA personalizados de AWS utilizando el software dedicado. SDK de neuronas de AWS. En su versión más reciente del SDK (versión 2.20.0), AWS presentó el Interfaz del núcleo neuronal (NKI) para desarrollar kernels personalizados para NeuronCore-v2el acelerador subyacente que impulsa a ambos amazon.com/machine-learning/trainium/” rel=”noopener ugc nofollow” target=”_blank”>trenio y amazon.com/machine-learning/inferentia/” rel=”noopener ugc nofollow” target=”_blank”>Inferencia2. La interfaz NKI se une a otra API que permite NeuronCore-v2 programabilidad, Operadores C++ personalizados de Neuron. En esta publicación exploraremos ambas oportunidades y las demostraremos en acción.
Descargos de responsabilidad
Es importante destacar que esta publicación no debe verse como un sustituto de la publicación oficial. Documentación del SDK de AWS Neuron. Al momento de escribir este artículo, las API del SDK de Neuron para el desarrollo de kernel personalizado se encuentran en versión Beta y pueden cambiar cuando lea esto. Los ejemplos que compartimos están destinados únicamente a fines demostrativos. No hacemos ninguna afirmación sobre su optimización, robustez, durabilidad o precisión. No considere nuestra mención de ninguna plataforma, herramienta, API, etc., como un respaldo para su uso. Las mejores opciones para cualquier proyecto dependen de las características específicas del caso de uso en cuestión y justifican una investigación y un análisis adecuados.
Aunque la lista de modelos de aprendizaje automático admitidos por Neuron SDK crece continuamente, algunas operaciones siguen sin admitirse o se implementan de manera subóptima. Al exponer las API para la personalización del kernel de Neuron, el SDK permite a los desarrolladores crear y/u optimizar las operaciones de bajo nivel que necesitan, lo que aumenta en gran medida la oportunidad de ejecutar cargas de trabajo de aprendizaje automático en Trainium e Inferentia.
Como comentamos en nuestras publicaciones anteriores de esta serie, aprovechar al máximo el poder de estos chips de IA requiere una comprensión detallada de su arquitectura de bajo nivel.
La arquitectura del núcleo neuronal
La documentación de NKI incluye una sección dedicada sobre el diseño de la arquitectura de NeuronCore-v2 y sus implicaciones en el desarrollo de operadores personalizados. Es importante destacar que existen muchas diferencias entre los núcleos Neuron y sus homólogos aceleradores de IA (por ejemplo, GPU y TPU). La optimización de los núcleos de Neuron requiere un conjunto único de estrategias y habilidades.
Al igual que otros chips de IA dedicados, NeuronCore-v2 incluye varios chips internos motores de aceleracióncada uno de los cuales se especializa en realizar ciertos tipos de cálculos. Los motores pueden funcionar de forma asíncrona y en paralelo. El Compilador de neuronas es responsable de transformar los modelos de ML en operaciones de bajo nivel y optimizar la elección del motor de cómputo para cada uno.
El motor tensorial Se especializa en multiplicación de matrices. El Vector y Escalar Ambos motores funcionan con tensores, con el motor vectorial especializado en operaciones de reducción y el motor escalar en funciones no lineales. gpsimd es un motor de propósito general capaz de ejecutar programas C/C++ arbitrarios. Tenga en cuenta que mientras el NKI La interfaz expone el acceso a los cuatro motores informáticos, operadores C++ personalizados están diseñados específicamente para el gpsimd.
Se pueden encontrar más detalles sobre las capacidades de cada motor en la documentación de arquitectura. Además, el Arquitectura del conjunto de instrucciones NKI (ISA) La documentación proporciona detalles sobre los motores en los que se ejecutan diferentes operaciones de bajo nivel.
Otro aspecto importante del chip Neuron es su arquitectura de memoria. Un dispositivo Neuron incluye tres tipos de memoria, HBM, SBUF y PSUM. Una comprensión íntima de las capacidades y posibilidades de cada uno es crucial para el desarrollo óptimo del núcleo.
Dada la descripción general de la arquitectura, se podría concluir que el desarrollo del kernel de Neuron requiere una gran experiencia. Si bien esto puede ser cierto para la creación de núcleos totalmente optimizados que aprovechen todas las capacidades del núcleo de Neuron, nuestro objetivo es demostrar la accesibilidad, el valor y el potencial de las API del núcleo personalizado de Neuron, incluso para desarrolladores no expertos.
El NKI La interfaz es una API de nivel Python que expone el uso de los motores informáticos centrales de Neuron y los recursos de memoria a los desarrolladores de ML. El Introducción al NKI La guía detalla las instrucciones de configuración y proporciona un aterrizaje suave con un kernel simple de “hola mundo”. El Modelo de programación NKI La guía detalla las tres etapas de un núcleo NKI típico (cargar entradas, ejecutar operaciones en los motores de cálculo y almacenar salidas) e introduce NKI Tile y Operaciones basadas en mosaicos. El Tutoriales de NKI demuestre una variedad de aplicaciones de muestra del kernel de NKI, cada una de las cuales presenta nuevas capacidades y API centrales de NKI. Dada la supuesta optimización de los núcleos de muestra, una posible estrategia para desarrollar nuevos núcleos podría ser 1) identificar una muestra que sea similar a la operación que desea implementar y luego 2) usarla como base y refinarla y ajustarla iterativamente para lograr la funcionalidad específica que necesita.
El Manual de referencia de la API de NKI detalla la API de Python para el desarrollo del kernel. Con una sintaxis y semántica similar a Tritón y NumPyel ¿QUÉ idioma? La definición tiene como objetivo maximizar la accesibilidad y la facilidad de uso. Sin embargo, es importante tener en cuenta que el desarrollo del kernel NKI se limita a las operaciones definidas en el NKI biblioteca, que (en el momento de escribir este artículo) son menos y más limitadas que en bibliotecas como Tritón y NumPy.
Ejemplo de juguete: un núcleo GIOU
Como en nuestras publicaciones anteriores, evaluamos el uso de NKI mediante la creación de una implementación personalizada del Intersección generalizada sobre unión (GIOU) operación en un par de lotes de cajas de entrada. Dado que GIOU implica operaciones de píxeles, utilizamos el exp. núcleo desde Programación NKI guía como punto de referencia e incorporó el uso de NKI indexación tensorial avanzada en nuestra implementación. Para facilitar la depuración en un entorno de CPU, también agregamos opciones para ejecutar el código usando el qué.simulate_kernel y nki.idioma.device_print.html API.
import torch
import neuronxcc.nki as nki
import neuronxcc.nki.language as nl
import numpy as npsimulate = False
try:
# if torch libraries are installed assume that we are running on Neuron
import torch_xla.core.xla_model as xm
import torch_neuronx
from torch_neuronx import nki_jit
device = xm.xla_device()
# empty implementation
def debug_print(*args, **kwargs):
pass
except:
# if torch libraries are not installed assume that we are running on CPU
# and program script to use nki simulation
simulate = True
nki_jit = nki.trace
debug_print = nl.device_print
device = 'cpu'
@nki_jit
def giou_kernel(preds_ptr,
targets_ptr,
output_ptr):
epsilon = 1e-5
TILE_M = nl.tile_size.pmax # 128
TILE_N = nl.tile_size.psum_fmax # 512
TILE_N_OUT = TILE_N // 4
p_1, p_2 = preds_ptr.shape
t_1, t_2 = targets_ptr.shape
o_1, o_2 = output_ptr.shape
# verify input
# batch size must be multiple of 128
assert p_1 % TILE_M == 0
assert p_1 == t_1
assert p_1 == o_1
# num boxes box *4 must be multiple of 512
assert p_2 % TILE_N == 0
assert p_2 == t_2
assert p_2 // 4 == o_2
num_tiles_m = p_1 // TILE_M
num_tiles_n = p_2 // TILE_N
# Generate tensors for advanced indexing
i_p = nl.arange(TILE_M)(:, None)
i_f = nl.arange(TILE_N // 4)(None, :)
i_f_0 = (4 * i_f)
i_f_1 = (4 * i_f + 1)
i_f_2 = (4 * i_f + 2)
i_f_3 = (4 * i_f + 3)
# Use affine_range to loop over tiles
for m in nl.affine_range(num_tiles_m):
for n in nl.affine_range(num_tiles_n):
# Load input data from HBM
preds = nl.load(preds_ptr(m * TILE_M:(m + 1) * TILE_M,
n * TILE_N:(n + 1) * TILE_N))
targets = nl.load(targets_ptr(m * TILE_M:(m + 1) * TILE_M,
n * TILE_N:(n + 1) * TILE_N))
debug_print('preds', preds)
preds_left = preds(i_p, i_f_0)
preds_top = preds(i_p, i_f_1)
preds_right = preds(i_p, i_f_2)
preds_bottom = preds(i_p, i_f_3)
gt_left = targets(i_p, i_f_0)
gt_top = targets(i_p, i_f_1)
gt_right = targets(i_p, i_f_2)
gt_bottom = targets(i_p, i_f_3)
# Compute the area of each box
area1 = (preds_right - preds_left) * (preds_bottom - preds_top)
area2 = (gt_right - gt_left) * (gt_bottom - gt_top)
# Compute the intersection
left = nl.maximum(preds_left, gt_left)
top = nl.maximum(preds_top, gt_top)
right = nl.minimum(preds_right, gt_right)
bottom = nl.minimum(preds_bottom, gt_bottom)
inter_w = nl.maximum(right - left, 0)
inter_h = nl.maximum(bottom - top, 0)
inter_area = inter_w * inter_h
union_area = area1 + area2 - inter_area
iou_val = inter_area / nl.maximum(union_area, epsilon)
# Compute the smallest enclosing box
enclose_left = nl.minimum(preds_left, gt_left)
enclose_top = nl.minimum(preds_top, gt_top)
enclose_right = nl.maximum(preds_right, gt_right)
enclose_bottom = nl.maximum(preds_bottom, gt_bottom)
enclose_w = nl.maximum(enclose_right - enclose_left, 0)
enclose_h = nl.maximum(enclose_bottom - enclose_top, 0)
enclose_area = enclose_w * enclose_h
# Compute GIOU
delta_area = (enclose_area - union_area)
enclose_area = nl.maximum(enclose_area, epsilon)
giou = iou_val - delta_area / enclose_area
# Store results
nl.store(output_ptr(m * TILE_M:(m + 1) * TILE_M,
n * TILE_N_OUT:(n + 1) * TILE_N_OUT),
giou)
Para ejecutar nuestro kernel GIOU, generamos dos lotes de cuadros aleatorios y los alimentamos a nuestra función:
# generate random data in np
np.random.seed(0)
batch_size = 1024
n_boxes = 256
img_size = 256
boxes = ()for i in range(2):
# Randomly generate box sizes and positions
box_sizes = np.random.randint(1, img_size, size=(batch_size,n_boxes,2))
top_left = np.random.randint(0, img_size-1, size=(batch_size,n_boxes,2))
bottom_right = np.clip(top_left + box_sizes, 0, img_size - 1)
# Concatenate top-left and bottom-right coordinates
rand_boxes = np.concatenate((top_left, bottom_right), axis=2)
boxes.append(rand_boxes.astype(np.float32))
out = np.empty((batch_size, n_boxes), np.float32)
# convert tensors to PyTorch
t_boxes_0 = torch.tensor(boxes(0)).to(device)
t_boxes_1 = torch.tensor(boxes(1)).to(device)
t_out = torch.tensor(out).to(device)
if simulate:
# the simulation API requires numpy input
nki.simulate_kernel(giou_kernel,
boxes(0).reshape((batch_size, -1)),
boxes(1).reshape((batch_size, -1)),
out)
else:
giou_kernel(t_boxes_0.view((batch_size, -1)),
t_boxes_1.view((batch_size, -1)),
t_out)
Para evaluar el rendimiento de nuestro kernel NKI, lo compararemos con la siguiente implementación ingenua de GIOU en PyTorch:
def torch_giou(boxes1, boxes2):
# loosely based on torchvision generalized_box_iou_loss code
epsilon = 1e-5# Compute areas of both sets of boxes
area1 = (boxes1(...,2)-boxes1(...,0))*(boxes1(...,3)-boxes1(...,1))
area2 = (boxes2(...,2)-boxes2(...,0))*(boxes2(...,3)-boxes2(...,1))
# Corners of intersection
lt = torch.max(boxes1(..., :2), boxes2(..., :2))
rb = torch.min(boxes1(..., 2:), boxes2(..., 2:))
# Width and height of intersection
wh = (rb - lt).clamp(min=0)
# Area of the intersection
inter = wh(..., 0) * wh(..., 1)
# Union of the two boxes
union = area1 + area2 - inter
iou = inter / union.clamp(epsilon)
# Corners of enclosing box
lti = torch.min(boxes1(..., :2), boxes2(..., :2))
rbi = torch.max(boxes1(..., 2:), boxes2(..., 2:))
# Width and height of the enclosing box
whi = (rbi - lti).clamp(min=0)
# Area of the enclosing box
areai = (whi(..., 0) * whi(..., 1)).clamp(epsilon)
return iou - (areai - union) / areai
Usamos la siguiente utilidad de evaluación comparativa para comparar el rendimiento en tiempo de ejecución de nuestras dos funciones:
import time
def benchmark(f, warmup_iters=20, ntrials: int = 100):
def run(*args, **kwargs):
# warmup
for _ in range(warmup_iters):
f(*args, **kwargs)
start_time = time.time()
for _ in range(ntrials):
f(*args, **kwargs)
end_time = time.time()
# Calculate average time per iteration
avg_time = (end_time - start_time) / ntrials
return avg_timereturn run
avg_time = benchmark(torch_giou)(t_boxes_0, t_boxes_1)
print(f'torch_giou: {avg_time}')
avg_time = benchmark(giou_kernel)(t_boxes_0.view((batch_size, -1)),
t_boxes_1.view((batch_size, -1)),
t_out)
print(f'giou_kernel: {avg_time}')
Entorno de ejecución
Ejecutamos nuestro script en un amazon.com/ec2/instance-types/inf2/” rel=”noopener ugc nofollow” target=”_blank”>amazon EC2 inf2.xlarge instancia (que contiene dos Núcleos de neuronas y cuatro vCPU). Utilizamos la versión más reciente del amazon.com/releasenotes/aws-deep-learning-ami-neuron-ubuntu-22-04/” rel=”noopener ugc nofollow” target=”_blank”>AMI de aprendizaje profundo para Neuron disponible en el momento de escribir este artículo, “Deep Learning AMI Neuron (Ubuntu 22.04) 20241027”, con AWS Neurona 2.20.1 y PyTorch 2.1.
Resultados
Nuestro kernel GIOU personalizado demostró un tiempo de ejecución promedio de 0,211 milisegundos en comparación con 0,293, lo que representa un aumento de rendimiento del 39 %. Tenga en cuenta que estos resultados son exclusivos de nuestro ejemplo de juguete. Es probable que otros operadores, en particular los que incluyen multiplicaciones de matrices (y utilizan el motor Tensor), muestren resultados comparativos diferentes.
Optimización del rendimiento del kernel NKI
El siguiente paso en el desarrollo de nuestro kernel, más allá del alcance de esta publicación, sería analizar el rendimiento del kernel GIOU utilizando el módulo dedicado. Perfilador de neuronas para identificar cuellos de botella y optimizar nuestra implementación. Por favor vea el Guía de rendimiento de NKI para más detalles.
El segundo método para crear un kernel Neuron personalizado es crear un operador C++ para el motor gpsimd. Este método se describe en el Guía para desarrolladores de operadores personalizados de C++ de Neuron y demostrado en el Operadores C++ personalizados de Neuron en MLP y Optimización del rendimiento de los operadores C++ personalizados de Neuron tutoriales.
Los operadores Neuron Custom C++ presentan una oportunidad para la “fusión del kernel” en el motor GpSimd al facilitar la combinación de múltiples operaciones de bajo nivel en una única ejecución del kernel. Este enfoque puede reducir significativamente la sobrecarga asociada con: 1) cargar varios núcleos individuales y 2) transferir datos entre diferentes regiones de memoria.
Ejemplo de juguete: un kernel GIOU C++
En el bloque de código siguiente implementamos un operador GIOU de C++ para Neuron y lo guardamos en un archivo llamado giou.cpp. Nuestro núcleo utiliza el Accesorio de medicina tradicional china para optimizar el rendimiento de lectura y escritura de la memoria y aplica el multinúcleo configuración para utilizar los ocho procesadores internos del GpSimd.
#include
#include
#include
#include
#include // input boxes of shape 1024x256x4
// output scores of shape 1024x256
torch::Tensor giou(const torch::Tensor& t_pred,
const torch::Tensor& t_target) {
size_t num_samples = t_pred.sizes()(0);
size_t num_boxes = t_pred.sizes()(1);
torch::Tensor t_out = get_dst_tensor();
// get the number of GpSimd processors (8 in NeuronCoreV2)
uint32_t cpu_count = get_cpu_count();
// get index of current processor
uint32_t cpu_id = get_cpu_id();
// divide the batch size into 8 partitions
uint32_t partition = num_samples / cpu_count;
// use tcm buffers to load and write data
size_t tcm_in_size = num_boxes*4;
size_t tcm_out_size = num_boxes;
float *tcm_pred = (float*)torch::neuron::tcm_malloc(
sizeof(float)*tcm_in_size);
float *tcm_target = (float*)torch::neuron::tcm_malloc(
sizeof(float)*tcm_in_size);
float *tcm_output = (float*)torch::neuron::tcm_malloc(
sizeof(float)*tcm_in_size);
auto t_pred_tcm_acc = t_pred.tcm_accessor();
auto t_target_tcm_acc = t_target.tcm_accessor();
auto t_out_tcm_acc = t_out.tcm_accessor();
// iterate over each of the entries in the partition
for (size_t i = 0; i < partition; i++) {
// load the pred and target boxes into local memory
t_pred_tcm_acc.tensor_to_tcm(tcm_pred,
partition*cpu_id + i*tcm_in_size,
tcm_in_size);
t_target_tcm_acc.tensor_to_tcm(tcm_target,
partition*cpu_id + i*tcm_in_size,
tcm_in_size);
// iterate over each of the boxes in the entry
for (size_t j = 0; j < num_boxes; j++) {
const float epsilon = 1e-5;
const float* box1 = &tcm_pred(j * 4);
const float* box2 = &tcm_target(j * 4);
// Compute area of each box
float area1 = (box1(2) - box1(0)) * (box1(3) - box1(1));
float area2 = (box2(2) - box2(0)) * (box2(3) - box2(1));
// Compute the intersection
float left = std::max(box1(0), box2(0));
float top = std::max(box1(1), box2(1));
float right = std::min(box1(2), box2(2));
float bottom = std::min(box1(3), box2(3));
float inter_w = std::max(right - left, 0.f);
float inter_h = std::max(bottom - top, 0.f);
float inter_area = inter_w * inter_h;
// Compute the union area
float union_area = area1 + area2 - inter_area;
// IoU
float iou_val = inter_area / std::max(union_area, epsilon);
// Compute the smallest enclosing box
float enclose_left = std::min(box1(0), box2(0));
float enclose_top = std::min(box1(1), box2(1));
float enclose_right = std::max(box1(2), box2(2));
float enclose_bottom = std::max(box1(3), box2(3));
float enclose_w = std::max(enclose_right - enclose_left, 0.f);
float enclose_h = std::max(enclose_bottom - enclose_top, 0.f);
float enclose_area = std::max(enclose_w * enclose_h, epsilon);
float result = iou_val - (enclose_area-union_area)/enclose_area;
tcm_output(j) = result;
}
// write the giou scores of all boxes in the current entry
t_out_tcm_acc.tcm_to_tensor(tcm_output,
partition*cpu_id + i*tcm_out_size,
tcm_out_size);
}
torch::neuron::tcm_free(tcm_pred);
torch::neuron::tcm_free(tcm_target);
return t_out;
}
Requerimos un separado forma.cpp archivo que define la forma de salida de nuestra función GIOU y registra nuestro operador personalizado con la biblioteca Neuron:
#include
#include
#include
#include "torchneuron/register.h"torch::Tensor giou_shape(torch::Tensor boxes1, torch::Tensor boxes2) {
torch::Tensor t_out = torch::zeros({boxes1.sizes()(0),
boxes1.sizes()(1)},
torch::kFloat);
return t_out;
}
NEURON_LIBRARY(my_ops, m) {
m.def("giou", &giou_shape, "giou");
}
El construir.py El script compila el operador C++ y lo expone como una API de Python:
import os
import torch_neuronx
from torch_neuronx.xla_impl import custom_opcustom_op.load(
name='giou',
compute_srcs=('giou.cpp'),
shape_srcs=('shape.cpp'),
build_directory=os.getcwd(),
multicore=True,
verbose=True
)
El script de compilación genera un libgiou.so biblioteca que contiene la implementación de nuestro operador GIOU de C++. En el bloque de código a continuación cargamos la biblioteca y medimos el rendimiento de nuestro kernel personalizado usando la utilidad de evaluación comparativa definida anteriormente:
from torch_neuronx.xla_impl import custom_op
custom_op.load_library('libgiou.so')avg_time = benchmark(torch.ops.my_ops.giou)(t_boxes_0, t_boxes_1)
print(f'C++ giou: {avg_time}')
Entorno de ejecución
Usamos el mismo entorno Neuron de nuestros experimentos NKI para compilar y probar nuestro kernel C++. Por favor tenga en cuenta el pasos de instalación que se requieren para el desarrollo de operadores C++ personalizados.
Resultados
Nuestro kernel C++ GIOU demostró un tiempo de ejecución promedio de 0,061 milisegundos, casi cinco veces más rápido que nuestra implementación básica. Es de suponer que esto es el resultado de la “fusión del núcleo”, como se analizó anteriormente.
La siguiente tabla resume los resultados del tiempo de ejecución de nuestros experimentos.
Tenga en cuenta que estos resultados son específicos del ejemplo de juguete y del entorno de ejecución utilizado en este estudio. Los resultados comparativos de otros núcleos pueden ser muy diferentes, dependiendo del grado en que puedan aprovechar los motores informáticos internos del núcleo Neuron.
La siguiente tabla resume algunas de las diferencias que observamos entre los dos métodos de personalización del kernel de AWS Neuron.
A través de su interfaz Python de alto nivel, las API de NKI exponen el poder de los motores de aceleración Neuron a los desarrolladores de ML de una manera accesible y fácil de usar. La biblioteca de operadores personalizados de C++ de bajo nivel permite una programabilidad aún mayor, pero está limitada a la motor gpsimd. Al combinar eficazmente ambas herramientas, los desarrolladores pueden aprovechar al máximo las capacidades de la arquitectura AWS Neuron.
Con la revolución de la IA en pleno apogeo, muchas empresas están desarrollando nuevos chips de IA avanzados para satisfacer la creciente demanda de informática. Si bien los anuncios públicos a menudo destacan el rendimiento de tiempo de ejecución, el ahorro de costos y la eficiencia energética de estos chips, varias capacidades centrales son esenciales para que estos chips y sus pilas de software sean realmente viables para el desarrollo de ML. Estas capacidades incluyen sólidas herramientas de depuración, análisis de rendimiento y utilidades de optimización, programabilidady más.
En esta publicación, nos centramos en las utilidades disponibles para programar los aceleradores de IA propios de AWS. amazon.com/machine-learning/trainium/” rel=”noopener ugc nofollow” target=”_blank”>trenio y amazon.com/machine-learning/inferentia/” rel=”noopener ugc nofollow” target=”_blank”>Inferenciay demostró su uso en la creación de operaciones de aprendizaje automático personalizadas. Estas herramientas permiten a los desarrolladores optimizar el rendimiento de sus modelos de aprendizaje automático en los chips de inteligencia artificial de AWS y abrir nuevas oportunidades para la innovación y la creatividad.