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:
parent
03b2189043
commit
a22862acd9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue