Implementando structs por valor en Dart FFI
Un análisis profundo sobre diseño de API y convenciones de llamada nativas.
Un análisis profundo del diseño de API y convenciones de llamada nativas
#En la versión Dart 2.12, ampliamos nuestra función de interoperabilidad con C, Dart FFI, con la capacidad de pasar structs por valor. Este artículo habla sobre lo que implicó añadir esta función al SDK de Dart. Si te interesan los detalles de implementación de lenguajes a bajo nivel o las convenciones de plataforma para pasar structs por valor, sigue leyendo.
Este artículo habla tanto del desarrollo de la API como de la determinación del ABI (Application Binary Interface) para la función de struct por valor. Durante los dos años que trabajamos en esta función (y otras funciones de Dart FFI), descubrimos muchas restricciones que requerían cambiar la API. El recorrido del ABI fue igualmente interesante, ilustrando que se pueden tomar múltiples enfoques para definir los detalles de un problema difícil.
Pasar por valor y pasar por referencia en C/C++
#Aquí hay un repaso rápido si no escribes código en C todos los días. Supongamos que tenemos el siguiente struct y funciones en C:
struct Coord {
double x;
double y;
Coord* next;
};
Coord TranslateByValue(Coord coord) {
coord.x = coord.x + 10.0;
coord.y = coord.y + 10.0;
return coord;
}
void TranslateByPointer(Coord* coord) {
coord->x = coord->x + 10.0;
coord->y = coord->y + 10.0;
}
Luego, podemos usar estas funciones en algo de código C simple. Digamos que tenemos una variable local c1:
Coord c1 = {10.0, 10.0, nullptr};
Si pasamos c1 a TranslateByValue, entonces el argumento se pasa por valor, lo que hace que el receptor opere efectivamente sobre una copia del struct:
Coord c2 = TranslateByValue(c1);
Esto significa que c1 permanece sin cambios.
Sin embargo, si pasamos c1 por referencia con un puntero a la memoria que contiene c1, entonces
c1 se muta en el lugar:
TranslateByPointer(&c1);
c1.x ahora contiene 20.0.
El recorrido del diseño de la API
#El prototipo original de Dart FFI ya tenía soporte para pasar punteros a structs. Sin embargo, rediseñamos la API múltiples veces para acomodar varios casos de uso y restricciones.
Diseño inicial
#Nuestro diseño inicial permitía asignar structs en memoria, pasar esos punteros a C y modificar los campos de los structs. Con ese enfoque, la clase
Struct extendía la clase Pointer:
@struct
class Coordinate extends Pointer<Void> {
@Double()
double x;
@Double()
double y;
@Pointer()
Coordinate next;
/// generated by @ffi.struct annotation
external static int sizeOf();
static Coordinate allocate({int count: 1}) =>
allocate<Uint8>(count: count * sizeOf()).cast();
}
final c = Coordinate.allocate()
..x = 10.0
..y = 10.0;
Los usuarios de Dart FFI escribieron el fragmento anterior, y los internos de Dart FFI generaron una implementación de sizeOf
y implementaciones de getter y setter para x, y, y next.
Sin embargo, hace dos años nos dimos cuenta de que este diseño tenía un problema. Al hacer que
Coordinate extendiera Pointer, no podíamos distinguir entre Coordinate
y Coordinate*.
Distinguir entre Coordinate y Coordinate*
#Introdujimos Struct en Dart FFI e hicimos que los structs extendieran esta clase:
abstract class Struct<S extends NativeType> extends NativeType {
final Pointer<S> addressOf;
}
Ahora un Pointer<Coordinate> en Dart representa un Coordinate* en C, y un
Coordinate en Dart representa un Coordinate en C.
Esto significaba que el campo next tenía el tipo Pointer<Coordinate>, lo que hacía que la anotación
@Pointer fuera redundante. Así que, eliminamos las anotaciones de Pointer.
class Coordinate extends Struct<Coordinate> {
@Double()
double x;
@Double()
double y;
Pointer<Coordinate> next;
}
Como ahora representábamos punteros a structs como objetos Pointer, empezamos a usar la factoría
allocate en Pointer:
final c = Pointer<Coordinate>.allocate();
Para obtener acceso a los campos de un Pointer<Coordinate>, necesitamos un objeto de tipo
Coordinate, porque ese objeto tiene los campos x, y, y next. Para esto, ya teníamos el método
load en Pointer.
c.load<Coordinate>().x = 10.0;
Por supuesto, tener que escribir <Coordinate> al llamar a load es verboso. (Tener que escribir un argumento de tipo era lo mismo para cargar un
int de Dart desde un Pointer<Uint8>.) La razón por la que necesitamos este argumento de tipo en
load es para especificar al sistema de tipos de Dart el tipo de retorno de este método.
Métodos de extensión al rescate
#Dart 2.7 introdujo métodos de extensión. Con los métodos de extensión, podíamos hacer
pattern matching
sobre el argumento de tipo T en Pointer<T>:
extension StructPointer<T extends Struct> on Pointer<T> {
external T get ref;
}
El pattern matching sobre el argumento de tipo nos permitió eliminar la verbosidad en los sitios de llamada:
c.ref.y = 10.0; // ref is pattern matched to be of type Coordinate.
También podíamos usar el pattern matching de métodos de extensión para hacer redundante el argumento de tipo de Struct<S>
, cambiando la definición de structs de usuario a:
class Coordinate extends Struct {
@Double()
double x;
@Double()
double y;
Pointer<Coordinate> next;
}
Antes, el argumento de tipo <S> restringía el campo Struct Pointer<S>
addressOf. En su lugar, cambiamos el campo a un getter de extensión:
extension StructPointer<T extends Struct> on Pointer<T> {
external Pointer<T> get addressOf;
}
Dejar de filtrar el almacenamiento de respaldo
#Al retornar un struct por valor desde C a Dart, no queremos hacer malloc de memoria C para guardar el struct, porque eso sería lento
y cargaría al usuario con liberarla. Así que, en su lugar, el struct se copia a un TypedData, y el
Coordinate puede tener un Pointer o un TypedData como almacenamiento de respaldo.
Sin embargo, addressOf, que fue introducido en el primer rediseño, tenía el tipo Pointer. Este tipo transmitía que siempre estaba respaldado por memoria C, pero esto ya no era cierto.
Así que, deprecamos addressOf.
Para optimizaciones
#El último paso es requerir que las invocaciones de varios métodos de Dart FFI, incluyendo los relacionados con structs, tengan argumentos de tipo constantes en tiempo de compilación:
extension StructPointer<T extends Struct> on Pointer<T> {
/// Must invoke with a constant [T].
external T get ref;
}
La invocación de métodos nos permite optimizar mejor el código y está más alineada con la semántica de C.
Ten en cuenta que este último cambio activa avisos de deprecación en Dart 2.12, y el cambio se aplica en Dart 2.13.
El recorrido de descubrimiento del ABI
#Ahora que la API está en su lugar, la siguiente pregunta es: ¿Dónde espera C estos structs cuando se pasan o se retornan por valor? Esto se conoce como el Application Binary Interface (ABI).
Documentación
#Lo natural es buscar documentación. ARM proporciona Procedure Call Standard for the Arm Architecture — ABI 2019Q1 y Procedure Call Standard for the ARM 64-bit Architecture (AArch64). Sin embargo, la documentación oficial de x86 y x64 desapareció de internet, resultando en que la gente busque esta información y recurra a mirrors no oficiales o ingeniería inversa.
Un vistazo rápido a la documentación muestra una variedad de ubicaciones para pasar structs por valor:
En múltiples registros de CPU y FPU.
En el stack.
Un puntero a una copia. (La copia está en el stack frame del llamador.)
Parcialmente en registros de CPU y parcialmente en el stack.
Cuando se pasan en el stack, hay algunas preguntas adicionales sobre cuál es la alineación requerida y si todos los registros de CPU y FPU no usados están bloqueados o rellenados.
Al retornar un struct por valor, el struct puede ser devuelto en dos ubicaciones:
En múltiples registros de CPU y FPU.
-
Escrito en una ubicación de memoria por el receptor, en cuyo caso el llamador pasa un puntero a esa ubicación de memoria. (Esta memoria reservada también está en el stack frame del llamador.)
Cuando se pasa un puntero a la ubicación del resultado, una pregunta adicional es si esto conflictúa con un registro de argumento de CPU normal.
Refactorizar la compilación de Dart FFI
#Esta investigación inicial fue suficiente para darnos cuenta de que teníamos que reingeniar una parte del pipeline del compilador de Dart FFI. Solíamos reutilizar el tipo
Location, que originalmente estaba destinado para compilar código Dart a assembly.
Sin embargo, en el ABI de Dart, nunca usamos ubicaciones de stack no alineadas a word o más de dos registros al mismo tiempo. Un experimento intentando extender el tipo
Location para soportar estas ubicaciones adicionales terminó en un diff enorme y complicado porque
Location se usa mucho en la Dart virtual machine.
Así que, en su lugar, reemplazamos el pipeline de compilación para Dart FFI.
Explorar los ABIs nativos
#Exploremos un poco los ABIs.
Supongamos que tenemos el siguiente struct y firma de función en C:
struct Struct3Bytes {
uint8_t a0;
uint8_t a1;
uint8_t a2;
};
Struct3Bytes MyFunction(Struct3Bytes, Struct3Bytes, Struct3Bytes,
Struct3Bytes, Struct3Bytes, Struct3Bytes,
Struct3Bytes, Struct3Bytes);
¿Cómo pasan varios ABIs estos structs en MyFunction?
En Linux en x64, hay 6 registros de argumento de CPU. El struct es lo suficientemente pequeño para caber en un solo registro, así que los primeros 6 argumentos van a los 6 registros de argumento de CPU, y los últimos 2 van al stack. Los argumentos del stack están alineados a 8 bytes. Y, el valor de retorno también cabe en un registro de CPU (ejemplo más grande).
rdi int64 Compound(size: 3)
rsi int64 Compound(size: 3)
rdx int64 Compound(size: 3)
rcx int64 Compound(size: 3)
r8 int64 Compound(size: 3)
r9 int64 Compound(size: 3)
S+0 Compound(size: 3)
S+8 Compound(size: 3)
=>
rax int64 Compound(size: 3)
Entonces, ¿qué pasa en Windows?
Es completamente diferente. Windows tiene solo 4 registros de argumento. Sin embargo, el primer registro se usa para pasar el puntero a la ubicación de memoria donde escribir el valor de retorno. Y, todos los argumentos se pasan por puntero a una copia, porque el tamaño del struct es 3 bytes, que no es potencia de 2.
Locations on Windows
Pointer(rdx int64) Compound(size: 3)
Pointer(r8 int64) Compound(size: 3)
Pointer(r9 int64) Compound(size: 3)
Pointer(S+0 int64) Compound(size: 3)
Pointer(S+8 int64) Compound(size: 3)
Pointer(S+16 int64) Compound(size: 3)
Pointer(S+24 int64) Compound(size: 3)
Pointer(S+32 int64) Compound(size: 3)
=>
Pointer(rcx int64, ret:rax int64) Compound(size: 3)
Veamos otro ejemplo: ARM32 en Linux y Android. Supongamos que tenemos el siguiente struct y firma de función en C:
struct Struct16Bytes {
float a0;
float a1;
float a2;
float a3;
};
Struct16Bytes MyFunction2(Struct16Bytes, float, Struct16Bytes);
Estos tipos específicos de structs se llaman homogéneos compuestos, porque solo contienen elementos idénticos. Y, los floats homogéneos con hasta 4 miembros se tratan de forma diferente a los structs normales. En este caso, Linux usa registros de punto flotante para los floats individuales en el struct.
Multiple(s0 float, s1 float, s2 float, s3 float) Compound(size: 16)
s4 float
Multiple(s5 float, s6 float, s7 float, s8 float) Compound(size: 16)
=>
Multiple(s0 float, s1 float, s2 float, s3 float) Compound(size: 16)
En Android, se usa SoftFP en lugar de HardFP. Esto significa que los floats se pasan en registros de enteros en lugar de registros de punto flotante. Además, estamos pasando un
Pointer para el resultado. Esto resulta en una situación curiosa
en la que el primer argumento se pasa parcialmente en registros de enteros y parcialmente en el stack.
M(r1 int32, r2 int32, r3 int32, S+0 int32) Compound(size: 16)
S+4 float
M(S+8 int32, S+12 int32, S+16 int32, S+20 int32) Compound(size: 16)
=>
P(r0 uint32) Compound(size: 16)
Equivocarse en cualquiera de esto probablemente lleve a fallos de segmentación en tiempo de ejecución. Por lo tanto, es primordial obtener correctamente todos los casos extremos del ABI en cada combinación de hardware y sistema operativo.
Explorar mediante godbolt.org
#Como la documentación es muy concisa, descubrimos muchos casos extremos a través del compilador explorer godbolt.org. El compilador explorer muestra código C y assembly compilado lado a lado:
La captura de pantalla anterior muestra que en Windows x86 sizeof(Struct3Bytes) es 3 bytes, porque 3 se mueve al registro de retorno
eax.
Cuando cambiamos ligeramente el struct, podemos inspeccionar si el tamaño sigue siendo 3:
typedef struct {
int16_t a0;
int8_t a1;
} Struct3Bytes;
El tamaño no es 3: mov eax, 4. Como el int16 debe estar alineado a 2 bytes, el struct debe estar alineado a 2 bytes. Eso significa que al asignar un array de estos structs hay un padding de 1 byte después de cada struct para asegurar que el siguiente struct esté alineado a 2 bytes. Por lo tanto, este struct es de 4 bytes en el ABI nativo.
Explorar mediante pruebas generadas
#Desafortunadamente, el compilador explorer no soporta MacOS e iOS. Así que, para hacer la exploración manual más eficiente (y para tener un buen y enorme conjunto de pruebas para esta función), escribimos un generador de pruebas.
La idea principal es generar pruebas de tal manera que si crashean sea posible usar GDB para ver qué está mal.
Una forma de facilitar ver qué está mal al encontrar un fallo de segmentación es hacer que todos los argumentos tengan valores predecibles y fáciles de reconocer. Por ejemplo, la siguiente prueba usa enteros consecutivos, para que estos valores enteros puedan ser fácilmente identificados en registros y en el stack:
void testPassStruct3BytesHomogeneousUint8x10() {
final a0Pointer = calloc<Struct3BytesHomogeneousUint8>();
final Struct3BytesHomogeneousUint8 a0 = a0Pointer.ref;
final a1Pointer = calloc<Struct3BytesHomogeneousUint8>();
// ...
a0.a0 = 1;
a0.a1 = 2;
a0.a2 = 3;
a1.a0 = 4;
// ...
final result = passStruct3BytesHomogeneousUint8x10(
a0, a1, a2, a3, a4, a5, a6, a7, a8, a9);
print("result = $result");
Expect.equals(465, result);
calloc.free(a0Pointer);
calloc.free(a1Pointer);
// ...
}
Otra forma de facilitar la encontrar problemas es añadir prints por todas partes. Por ejemplo, si no encontramos un fallo de segmentación durante la transición de Dart a C, pero logramos corromper todos los argumentos, entonces imprimir los argumentos ayuda:
int64_t
PassStruct3BytesHomogeneousUint8x10(Struct3BytesHomogeneousUint8 a0,
Struct3BytesHomogeneousUint8 a1,
// ...
) {
std::cout << "PassStruct3BytesHomogeneousUint8x10"
<< "((" << static_cast<int>(a0.a0) << ", "
<< static_cast<int>(a0.a1) << ", " << static_cast<int>(a0.a2)
<< "), (" << static_cast<int>(a1.a0) << ", ") << // ...
int64_t result = 0;
result += a0.a0;
result += a0.a1;
result += a0.a2;
result += a1.a0;
// ...
std::cout << "result = " << result << "\n";
return result;
}
Añadir una prueba es tan fácil como añadir un tipo de función en el archivo de configuración. La capacidad de añadir pruebas rápidamente ha resultado en un enorme conjunto de pruebas.
Efectivamente, este conjunto de pruebas encontró otro caso curioso en un ABI nativo — esta vez en iOS-ARM64. Los argumentos no-struct en el stack en iOS en ARM64 no están alineados al tamaño de word sino a su propio tamaño. Los structs están alineados al tamaño de word, excepto que si el struct es un struct homogéneo con solo floats, entonces está alineado al tamaño del float.
Resumen
#Esto concluye nuestro recorrido por el diseño de la API y el descubrimiento del ABI. Con un buen conjunto de pruebas y revisiones de código exhaustivas, incorporamos el soporte para pasar structs por valor en Dart FFI en diciembre de 2020 en la rama master, y está disponible en Dart 2.12. Si estás interesado en usar Dart FFI, puedes empezar con la documentación de interoperabilidad con C en dart.dev. Si tienes alguna pregunta o comentario sobre el diseño de la API y el descubrimiento del ABI, siéntete libre de dejar un comentario abajo. ¡Nos encantaría saber de ti!
Gracias al equipo del lenguaje Dart y al (resto del) equipo de la Dart virtual machine por sus contribuciones a esta función de Dart FFI, y gracias a Kathy Walrath y Michael Thomsen por dar forma a este blog post!
Más de Dart
Historia del interop con JS en Dart
El soporte para Wasm acaba de llegar al beta actual de Flutter, gracias a un emocionante hito de interop con JavaScript alcanzado en Dart 3.3. Para…
Dart DevTools: Analizando el rendimiento de la aplicación con el CPU Profiler
Ya seas un desarrollador de backend escribiendo una herramienta de línea de comandos con Dart, o un ingeniero de UX construyendo aplicaciones con Flutter, el rendimiento del programa es crítico para el éxito de tu proyecto. Las herramientas de línea de comandos deberían minimizar la latencia y las apps deberían ser responsivas y ágiles sin fotogramas perdidos. Como desarrolladores, hacemos todo lo posible para escribir código performante pero a veces no está claro por qué nuestro código no funciona tan bien como esperaríamos.