Reducción del consumo de memoria en librsvg, parte 2: SpecifiedValues

Translations: en - Tags: gnome, librsvg, performance, rust

Para continuar con el tema de la vez pasada, vamos a ver cómo reducir el tamaño en memoria de los nodos del DOM en librsvg. Desde entonces ha habido cambios en el código; por eso es que en este artículo los nombres de algunos tipos han cambiado con respecto al artículo anterior.

Cada elemento del SVG se representa con esta estructura:

pub struct Element {
    element_type: ElementType,
    element_name: QualName,
    id: Option<String>,
    class: Option<String>,
    specified_values: SpecifiedValues,
    important_styles: HashSet<QualName>,
    result: ElementResult,
    transform: Transform,
    values: ComputedValues,
    cond: bool,
    style_attr: String,
    element_impl: Box<dyn ElementTrait>,
}

Los dos campos más grandes son los que tienen tipos SpecifiedValues y ComputedValues. He aquí los tamaños de la estructura completa Element y los de esos tipos:

sizeof Element: 1808
sizeof SpecifiedValues: 824
sizeof ComputedValues: 704

En este artículo vamos a reducir el tamaño de SpecifiedValues.

¿Qué es SpecifiedValues?

Si tenemos un elemento así:

<circle cx="10" cy="10" r="10" stroke-width="4" stroke="blue"/>

Los valores de propiedades de estilos stroke-width y stroke se guardan en un SpecifiedValues; esta estructura tiene un montón de campos, uno para cada propiedad de estilos:

pub struct SpecifiedValues {
    baseline_shift:              SpecifiedValue<BaselineShift>,
    clip_path:                   SpecifiedValue<ClipPath>,
    clip_rule:                   SpecifiedValue<ClipRule>,
    /// ...
    stroke:                      SpecifiedValue<Stroke>,
    stroke_width:                SpecifiedValue<StrokeWidth>,
    /// ...
}

Cada campo es un SpecifiedValue<T> por la siguiente razón. En CSS/SVG, una propiedad de estilos puede estar no especificada, o ser inherit para forzar que se copie la propiedad del elemento padre, o un valor específico. Librsvg lo representa así:

pub enum SpecifiedValue<T>
where
    T: // algunos requerimientos de traits aquí
{
    Unspecified,
    Inherit,
    Specified(T),
}

Ahora bien, SpecifiedValues tiene un montón de campos, 47 para ser exactos — uno por cada una de las propiedades de estilos que soporta librsvg. Por eso es que el tamaño de SpecifiedValues es de 824 bytes. Es la sub-estructura más grande dentro de Element, y sería bueno reducirle el tamaño.

No todas las propiedades se especifican

Volvamos a ver el pedacito de SVG de arriba.

<circle cx="10" cy="10" r="10" stroke-width="4" stroke="blue"/>

Aquí sólo se especifican dos de las propiedades, de modo que los campos stroke_width y stroke de SpecifiedValues van a quedar como SpecifiedValue::Specified(algo) y todos los demás van a quedar como SpecifiedValue::Unspecified.

Sería bueno sólo almacenar los valores completos para las propiedades que están especificadas, y una bandera más pequeña para las propiedades que están sin especificar.

Otra forma de representar el conjunto de propiedades

Como hay un máximo de 47 propiedades por elemento (o más si librsvg añade soporte para adicionales), podemos tener un arreglito de 47 bytes. Cada byte contiene el índice en otro arreglo que sólo contiene el valor de una propiedad especificada, o un valor centinela para indicar que la propiedad no está especificada.

Primero hice una enumeración que quepa en un u8 para todas las propiedades, más el valor centinela al final, que además nos da el número total de propiedades. El #[repr(u8)] nos garantiza que ese enum cabe en un byte.

#[repr(u8)]
enum PropertyId {
    BaselineShift,
    ClipPath,
    ClipRule,
    Color,
    // ...
    WritingMode,
    XmlLang,
    XmlSpace,
    UnsetProperty, // el número de propiedades y el valor centinela
}

Además, desde antes ya había este monstruo para representar "cuál propiedad" además del valor de la propiedad:

pub enum ParsedProperty {
    BaselineShift(SpecifiedValue<BaselineShift>),
    ClipPath(SpecifiedValue<ClipPath>),
    ClipRule(SpecifiedValue<ClipRule>),
    Color(SpecifiedValue<Color>),
    // ...
}

Cambié la definición de SpecifiedValues para que tenga dos arreglos, uno que indica qué propiedades están especificadas, y otro sólo con las propiedades especificadas:

pub struct SpecifiedValues {
    indices: [u8; PropertyId::UnsetProperty as usize],
    props: Vec<ParsedProperty>,
}

Hay una cosa que es incómoda en Rust, o que no he sabido resolver mejor: dada una ParsedProperty, hay que encontrar el PropertyId correspondiente para su discriminante. Puse lo obvio:

impl ParsedProperty {
    fn get_property_id(&self) -> PropertyId {
        use ParsedProperty::*;

        match *self {
            BaselineShift(_) => PropertyId::BaselineShift,
            ClipPath(_)      => PropertyId::ClipPath,
            ClipRule(_)      => PropertyId::ClipRule,
            Color(_)         => PropertyId::Color,
            // ...
        }
    }
}

Inicialización

Primero, queremos inicializar un SpecifiedValues vacío, en donde todo los elementos del arreglo indices están puesto al valor sentinela que indica que la propiedad correspondiente no está especificada:

impl Default for SpecifiedValues {
    fn default() -> Self {
        SpecifiedValues {
            indices: [PropertyId::UnsetProperty.as_u8(); PropertyId::UnsetProperty as usize],
            props: Vec::new(),
        }
    }
}

Eso pone el campo indices a un arreglo lleno del valor sentinela PropertyId::UnsetProperty. Además, el arreglo props está vacío; ni siquiera se ha pedido un bloque de memoria para él. Así, los elementos del SVG que no tienen propiedades de estilos no ocupan memoria extra.

¿Qué propiedades están especificadas y cuáles son sus índices?

Segundo, queremos una función que nos dé el índice en props de alguna propiedad, o que nos diga si esa propiedad no está especificada aún:

impl SpecifiedValues {
    fn property_index(&self, id: PropertyId) -> Option<usize> {
        let v = self.indices[id.as_usize()];

        if v == PropertyId::UnsetProperty.as_u8() {
            None
        } else {
            Some(v as usize)
        }
    }
}

(Si alguien pasa id = PropertyId::UnsetProperty, el acceso al arreglo indices va a mandar un panic, que es lo que queremos, pues ese no es identificador válido para una propiedad.)

Cambiar el valor de una propiedad

Tercero, queremos poner el valor de una propiedad que no estaba especificada, o cambiar el valor de una que ya lo estaba:

impl SpecifiedValues {
    fn replace_property(&mut self, prop: &ParsedProperty) {
        let id = prop.get_property_id();

        if let Some(index) = self.property_index(id) {
            self.props[index] = prop.clone();
        } else {
            self.props.push(prop.clone());
            let pos = self.props.len() - 1;
            self.indices[id.as_usize()] = pos as u8;
        }
    }
}

En el primer caso del if, la propiedad ya estaba puesta y nada más remplazamos su valor. En el segundo caso, la propiedad no estaba puesta; la añadimos al arreglo props y guardamos su índice resultante en indices.

Resultados

Antes:

sizeof Element: 1808
sizeof SpecifiedValues: 824

Después:

sizeof Element: 1056
sizeof SpecifiedValues: 72

El archivo patológico de la vez anterior consumía 463,412,720 bytes en memoria antes de estos cambios. Después de los cambios, consume 314,526,136 bytes.

También medí el consumo de memoria de un un archivo normal, en este caso uno con muchos de los iconos simbólicos de GNOME. La versión anterior consume 17 MB; la versión nueva sólo 13 MB.

Cómo seguir ajustando esto

Por ahora, estoy satisfecho con SpecifiedValues, aunque todavía se podría hacer más pequeño:

  • El crate tagged-box convierte un enum como el ParsedProperty en un enum-de-boxes, y codifica el discriminante del enum en el puntero que apunta al box. De esta forma cada variante ocupa el mínimo posible de memoria, aunque se le suma un bloque de memora extra, y el contenedor en sí ocupa un solo puntero. No estoy seguro si valga la pena; cada ParsedProperty ocupa 64 bytes, pero el arreglo plano de props: Vec<ParsedProperty> queda muy lindo en un solo bloque de memoria. No he visto los tamaños de cada propiedad individual para ver si varían mucho entre sí.

  • Buscar un crate para poder tener las propiedades en un sólo bloque de memoria, una especie de arena de tipos variables. Esto se puede implementar con un poquito de unsafe, pero hay que tener cuidado con la alineación de los elementos de diferentes tipos.

  • El crate enum_set2 representa un arreglo de enums sin campos como un arreglo de bits compacto. Si se cambiara la representación de SpecifiedValue, esto reduciría el arreglo indices al mínimo.

Si alguien quiere dedicarle tiempo a implementar y medir algo así, le estaría muy agradecido.

Siguientes pasos

Según Massif, lo siguiente es seguir haciendo que Element sea más pequeño. Lo que sigue de reducir de tamaño es ComputedValues. La opción obvia es hacerle exactamente lo mismo que a SpecifiedValues. No estoy seguro de si vale más la pena intentar compartir las estructuras de estilos entre varios elementos.