Кеширование скомпилированных спецификаций

Кеширование скомпилированных спецификаций в Vega и Vega-Lite опирается на разделение двух стадий работы: декларативного описания визуализации и её императивного выполнения внутри рантайма. Компиляция Vega-Lite-спецификации в Vega-спецификацию — одна из самых затратных операций в цепочке рендеринга, и именно она становится первым кандидатом на кэширование при построении интерактивных дашбордов и высоконагруженных интерфейсов визуализации.


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

Ключевые этапы:

  • парсинг и нормализация входной спецификации
  • валидация схемы
  • трансформация в Vega runtime specification
  • построение графа данных (dataflow graph)
  • компиляция сигналов и выражений
  • подготовка runtime view

Наиболее дорогими оказываются:

  • рекурсивная нормализация вложенных трансформаций (transform, facet, layer)
  • генерация сигналов интерактивности
  • разрешение scale/axis/band вычислений
  • построение dataflow графа

Именно эти стадии дают основную мотивацию к кэшированию.


Базовая идея кэширования компиляции

Кеширование компиляции основано на предположении, что одна и та же входная спецификация всегда порождает идентичную выходную Vega-спецификацию.

Формально:

compile(spec) -> vegaSpec
cache[hash(spec)] = vegaSpec

При повторном вызове:

if cache.has(hash(spec)):
    return cache.get(hash(spec))
else:
    compile and store

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

  • спецификация может содержать динамические выражения
  • данные могут быть внешними и изменяемыми
  • присутствуют runtime-сигналы и пользовательские взаимодействия
  • используется частичная параметризация (например, через params в Vega-Lite)

Поэтому простое структурное сравнение недостаточно.


Хеширование спецификаций

Основой кэширования выступает детерминированное хеширование JSON-структуры.

Нормализация перед хешированием

Перед вычислением ключа выполняется нормализация:

  • сортировка ключей объектов
  • удаление незначимых полей (например, description, title при включённой оптимизации)
  • нормализация выражений (expr) в строковый канонический формат
  • унификация порядка трансформаций при допустимой перестановке

Пример нормализации:

function normalizeSpec(spec) {
  if (Array.isArray(spec)) {
    return spec.map(normalizeSpec);
  }
  if (spec && typeof spec === "object") {
    const sortedKeys = Object.keys(spec).sort();
    const result = {};
    for (const k of sortedKeys) {
      if (k === "description") continue;
      result[k] = normalizeSpec(spec[k]);
    }
    return result;
  }
  return spec;
}

После нормализации применяется хеш-функция:

import crypto fr om "crypto";

function hashSpec(spec) {
  const normalized = normalizeSpec(spec);
  return crypto
    .createHash("sha256")
    .update(JSON.stringify(normalized))
    .digest("hex");
}

Стратегии кэширования

1. Простое in-memory кеширование

Наиболее распространённый подход:

const compileCache = new Map();

function compileVegaLite(spec) {
  const key = hashSpec(spec);

  if (compileCache.has(key)) {
    return compileCache.get(key);
  }

  const compiled = compileToVega(spec);
  compileCache.set(key, compiled);

  return compiled;
}

Ограничение подхода — неуправляемый рост памяти.


2. LRU-кэш для ограничения памяти

Использование LRU (Least Recently Used) структуры:

class LRUCache {
  constructor(lim it = 100) {
    this.limit = limit;
    this.map = new Map();
  }

  get(key) {
    if (!this.map.has(key)) return null;
    const value = this.map.get(key);
    this.map.delete(key);
    this.map.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.map.has(key)) {
      this.map.delete(key);
    }
    this.map.set(key, value);

    if (this.map.size > this.limit) {
      const firstKey = this.map.keys().next().value;
      this.map.delete(firstKey);
    }
  }
}

Использование:

const compileCache = new LRUCache(50);

function compileVegaLite(spec) {
  const key = hashSpec(spec);
  const cached = compileCache.get(key);

  if (cached) return cached;

  const compiled = compileToVega(spec);
  compileCache.set(key, compiled);

  return compiled;
}

Кэширование на уровне Vega View

Внутри Vega важным объектом является View, который представляет исполняемую визуализацию.

Компиляция Vega-Lite → Vega → View может быть разделена:

  • кэш спецификации (Vega-Lite → Vega)
  • кэш runtime-структуры (Vega → View)

Разделение позволяет повторно использовать уже скомпилированные графы данных при изменении только данных.


Инкрементальное кэширование при обновлении данных

Одно из ключевых преимуществ архитектуры заключается в том, что изменение данных не требует повторной компиляции спецификации.

const vegaSpec = compileCache.get(specHash);

const view = new vega.View(vega.parse(vegaSpec))
  .renderer("canvas")
  .initialize("#vis");

view.change("table", vega.changeset().remove(() => true).insert(newData));
view.run();

В этом сценарии кэшируется именно vegaSpec, а не результат рендера.


Кэширование трансформаций данных

Отдельный уровень оптимизации связан с повторным использованием вычисленных трансформаций:

  • filter
  • aggregate
  • joinaggregate
  • window
  • calculate

При идентичной комбинации трансформаций и одинаковом входном наборе данных возможно кэширование промежуточных результатов.

const transformCache = new Map();

function applyTransforms(data, transforms) {
  const key = hashSpec({ data, transforms });

  if (transformCache.has(key)) {
    return transformCache.get(key);
  }

  const result = runTransforms(data, transforms);
  transformCache.set(key, result);

  return result;
}

Кэширование выражений и сигналов

Vega использует систему сигналов (signals) для реактивных вычислений.

Сигналы могут быть компилированы в выражения, и эти выражения также поддаются кэшированию:

  • арифметические выражения шкал
  • вычисление tooltip координат
  • интерактивные drag/zoom операции

Пример:

const exprCache = new Map();

function compileEx * pression(expr) {
  if (exprCache.has(expr)) return exprCache.get(expr);

  const compiled = vega.expressionFunction(expr);
  exprCache.set(expr, compiled);

  return compiled;
}

Кэширование в контексте параметризованных визуализаций

Vega-Lite поддерживает params, что приводит к частично стабильным спецификациям.

Проблема: два объекта с одинаковой структурой, но разными параметрами могут иметь различную семантику.

Решение — разделение ключа кэша:

cacheKey = hash(staticSpec) + hash(params)

Пример:

function compile(spec, params) {
  const key = hashSpec(spec) + "::" + hashSpec(params);

  if (compileCache.has(key)) return compileCache.get(key);

  const compiled = compileToVega(spec, params);
  compileCache.set(key, compiled);

  return compiled;
}

Частичное кэширование графа данных

Внутри Vega runtime граф данных представляет собой DAG (Directed Acyclic Graph). Возможна стратегия:

  • кэшировать узлы графа
  • переиспользовать подграфы трансформаций
  • разделять immutable и mutable части

Особенно эффективно при использовании:

  • многослойных (layer) визуализаций
  • фасетирования (facet)
  • повторяющихся шкал (scale reuse)

Сериализация кэша и холодный старт

В серверных сценариях применяется сериализация кэша:

  • хранение compiled Vega specs на диске
  • восстановление LRU-структуры при запуске
  • дедупликация популярных визуализаций
import fs from "fs";

function saveCache(cache) {
  fs.writeFileSync("cache.json", JSON.stringify([...cache]));
}

function loadCache() {
  const data = JSON.parse(fs.readFileSync("cache.json"));
  return new Map(data);
}

Проблемы консистентности

Кэширование компиляции сталкивается с рядом проблем:

  • зависимость от версии Vega/Vega-Lite
  • различия runtime окружений (canvas vs svg)
  • нестабильные выражения времени (time(), now())
  • внешние источники данных (URL, API)

Для контроля вводятся версии ключей:

cacheKey = hash(spec) + version(vega) + version(vegaLite)

Гранулярное кэширование и оптимизация пайплайна

Наиболее эффективная стратегия — многоуровневое кэширование:

  • уровень 1: нормализованная Vega-Lite спецификация
  • уровень 2: скомпилированная Vega спецификация
  • уровень 3: граф данных (dataflow DAG)
  • уровень 4: вычисленные трансформации
  • уровень 5: runtime view состояния

Такая архитектура позволяет минимизировать повторные вычисления даже при частичной модификации визуализации.


Итоговая модель кэширования компиляции

Обобщённая схема:

Vega-Lite spec
   ↓ (cache A)
normalized spec hash
   ↓
Vega spec
   ↓ (cache B)
parsed runtime graph
   ↓ (cache C)
view instance

Каждый слой имеет собственную стратегию invalidation, зависящую от типа изменений: структура, данные или интерактивные параметры.