perf(compile): cache built artifacts + skip the PSRAM boot memtest

Running a gallery example took 51 s before a single pixel appeared.
Measured split: 28 s compile, 23 s emulator boot. Both halves had
avoidable work.

Compile: the dedup only collapsed builds still IN FLIGHT, so an
identical rebuild cost the same as a cold one (28 s cold, 27 s warm).
Successful results are now stored under the build volume keyed by the
same content hash, LRU-pruned, and served as an already-finished job so
the client's poll loop is unchanged. Gallery examples are byte-identical
for every visitor, so one build serves everyone: 32.8 s -> 0.6 s.
The key also folds in a fingerprint of the two modules that TURN a
request into build flags, so changing sdkconfig generation invalidates
every stale binary automatically instead of serving images built with
the old flags. Failed builds are never cached.

Boot: CONFIG_SPIRAM_MEMTEST defaults on and walks all 32 MB before
app_main - tens of millions of guest accesses to test RAM that is
modelled, not physical. Off for both the P4 hex and the classic
quad/octal chunks: 35M -> 20M guest instructions to LCD-READY.

End to end on staging, ESP32-P4 MIPI-DSI example: 51 s -> 11 s.
This commit is contained in:
David Montero Crespo 2026-08-20 05:06:20 +02:00
parent 03b2189043
commit a22862acd9
2 changed files with 135 additions and 0 deletions

View File

@ -1,8 +1,11 @@
import asyncio
import hashlib
import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@ -34,6 +37,92 @@ COMPILE_JOBS: dict[str, dict[str, Any]] = {}
JOB_BY_KEY: dict[str, str] = {} # content_hash → job_id, for deduplication
JOB_TTL_S = 1800 # purge results 30 min after completion
# ── Artifact cache ────────────────────────────────────────────────────────
# The dedup above only collapses builds that are still in flight, so hitting
# Run twice on the same gallery example rebuilt it from scratch: measured 28 s
# cold and 27 s warm for an ESP-IDF P4 example, all of it before the emulator
# even starts. Gallery examples are byte-identical for every visitor, so the
# first build can serve everyone. Results are stored under the build volume,
# keyed by the same content hash the dedup uses.
ARTIFACT_CACHE_DIR = Path(
os.environ.get("VELXIO_ARTIFACT_CACHE", "/var/lib/velxio-build/artifacts")
)
ARTIFACT_CACHE_MAX_ENTRIES = 400
ARTIFACT_CACHE_MAX_AGE_S = 14 * 24 * 3600
def _toolchain_epoch() -> str:
"""Fingerprint the code that TURNS a request into build flags.
A cached binary is only valid while the sdkconfig/CLI generation behind it
is unchanged — flipping CONFIG_SPIRAM_MEMTEST off, say, must not keep
serving images built with it on. Hashing the two service modules makes the
invalidation automatic: edit them, every key changes.
"""
h = hashlib.sha256()
here = Path(__file__).resolve().parents[2] / "services"
for name in ("espidf_compiler.py", "arduino_cli.py"):
try:
h.update((here / name).read_bytes())
except OSError:
h.update(b"?")
return h.hexdigest()[:16]
_TOOLCHAIN_EPOCH = _toolchain_epoch()
def _artifact_path(key: str) -> Path:
return ARTIFACT_CACHE_DIR / f"{key}.json"
def _artifact_load(key: str) -> dict | None:
"""Return a cached CompileResponse dict, or None. Never raises."""
path = _artifact_path(key)
try:
if not path.is_file():
return None
if time.time() - path.stat().st_mtime > ARTIFACT_CACHE_MAX_AGE_S:
path.unlink(missing_ok=True)
return None
data = json.loads(path.read_text())
os.utime(path, None) # LRU: touch on read
return data if isinstance(data, dict) else None
except Exception:
logger.warning("[compile] artifact cache read failed", exc_info=True)
return None
def _artifact_store(key: str, result: dict) -> None:
"""Persist a SUCCESSFUL build. Failures are never cached — a compile error
is usually the user's half-written code, and they will edit and retry."""
if not result.get("success"):
return
try:
ARTIFACT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
# The build log is per-run noise and can be hundreds of KB; a cache hit
# says so in its place rather than replaying someone else's ninja run.
slim = dict(result)
slim["stdout"] = "[served from the build cache - identical sources]"
tmp = _artifact_path(key).with_suffix(".tmp")
tmp.write_text(json.dumps(slim))
tmp.replace(_artifact_path(key))
_artifact_prune()
except Exception:
logger.warning("[compile] artifact cache write failed", exc_info=True)
def _artifact_prune() -> None:
"""Keep the newest ARTIFACT_CACHE_MAX_ENTRIES files (LRU by mtime)."""
try:
files = sorted(
ARTIFACT_CACHE_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True
)
for stale in files[ARTIFACT_CACHE_MAX_ENTRIES:]:
stale.unlink(missing_ok=True)
except Exception:
pass
# ── Concurrency control ──────────────────────────────────────────────────────
# Two lanes. HEAVY = ESP-IDF (cmake + ninja, minutes on a cold cache): capped
# at 2 — the VPS is modest (saw load avg 30 with 6 ninja processes peeling
@ -120,6 +209,10 @@ def _job_key(
so the build is owner-independent).
"""
h = hashlib.sha256()
# Bind every key to the code that generates build flags: a binary cached
# before a sdkconfig change must not be served after it.
h.update(_TOOLCHAIN_EPOCH.encode())
h.update(b"\0")
h.update(board_fqbn.encode())
h.update(b"\0")
for f in sorted(files, key=lambda x: x["name"]):
@ -554,6 +647,8 @@ async def _compile_job(
# result.stdout once state=done, but having both costs nothing).
"stdout_buffer": COMPILE_JOBS.get(job_id, {}).get("stdout_buffer", ""),
}
if job_key:
_artifact_store(job_key, response.model_dump())
error_kind = (
None if response.success
else _classify_compile_error(response.stderr, response.error)
@ -727,6 +822,39 @@ async def compile_start(
logger.info(f"[compile] dedup hit — reusing job {existing_id}")
return CompileStartResponse(job_id=existing_id)
# Same sources, same flags, already built: hand the stored artifact back as
# an already-finished job so the client's normal poll loop just sees `done`.
cached = _artifact_load(key)
if cached is not None:
job_id = uuid.uuid4().hex
now = time.time()
COMPILE_JOBS[job_id] = {
"state": "done",
"started_at": now,
"finished_at": now,
"result": cached,
"key": key,
"stdout_buffer": cached.get("stdout", ""),
}
logger.info("[compile] artifact cache hit — skipping the build")
await _record_async_metric(
user_id=user_id,
project_id=request.project_id,
board_fqbn=request.board_fqbn,
success=True,
duration_ms=0,
error_kind=None,
extra={
"file_count": len(files),
"async": True,
"cached": True,
"initiated_by": request.initiated_by,
"board_kind": request.board_kind,
"example_id": request.example_id,
},
)
return CompileStartResponse(job_id=job_id)
job_id = uuid.uuid4().hex
COMPILE_JOBS[job_id] = {"state": "pending", "started_at": time.time(), "key": key}
JOB_BY_KEY[key] = job_id

View File

@ -3038,10 +3038,17 @@ class ESPIDFCompiler:
psram_chunks.append('CONFIG_SPIRAM_USE_MALLOC=y')
psram_chunks.append('CONFIG_SPIRAM_MODE_HEX=y')
psram_chunks.append('CONFIG_SPIRAM_SPEED_200M=y')
# The boot-time memory test walks the WHOLE 32 MB before app_main;
# on emulated silicon that is tens of millions of guest accesses of
# pure startup latency ("Disable this for slightly faster startup",
# Kconfig.spiram.common). The RAM is modelled, not physical - there
# is nothing to find.
psram_chunks.append('CONFIG_SPIRAM_MEMTEST=n')
else:
psram_chunks.append('CONFIG_SPIRAM=y')
psram_chunks.append('CONFIG_SPIRAM_USE_MALLOC=y')
psram_chunks.append('CONFIG_SPIRAM_SPEED_80M=y')
psram_chunks.append('CONFIG_SPIRAM_MEMTEST=n') # see the P4 note
if psram_mode == 'opi':
psram_chunks.append('CONFIG_SPIRAM_MODE_OCT=y')
else: