mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 17:05:36 +00:00
[core] Chunk setup() into per-component noinline IIFEs
Generated setup() is a single monolithic function whose stack frame scales super-linearly with config size. On a 5,943-line apollo build the frame reached 1,264 B at -Os; extrapolation onto larger configs (e.g. the 16k-line LVGL config in #15796) plausibly overflows the 8 KB loop task stack before safe_mode can increment its boot counter. Emit a ComponentMarker sentinel at the start of each component's to_code output, then have cpp_main_section wrap each component's block (and sub-splits of up to 50 statements within each block) in a noinline IIFE lambda. Each lambda's ENTRY frame is released on return, bounding peak stack to setup() frame + max chunk frame. Measured on apollo-r-pro-1-eth (esp32-s3, -Os): setup() frame 1264 B -> 160 B max chunk frame n/a -> 144 B peak setup stack 1264 B -> 304 B (-76%) total flash 792,471 B -> 791,995 B (-476 B) The brace-depth guard in _wrap_in_noinline_iifes ensures we never split between the RawStatement("{") / RawStatement("}") pair emitted by cg.with_local_variable() (currently only wifi), so scoped locals stay intact.
This commit is contained in:
@@ -569,6 +569,7 @@ def wrap_to_code(name, comp):
|
||||
|
||||
@functools.wraps(comp.to_code)
|
||||
async def wrapped(conf):
|
||||
cg.add(cg.ComponentMarker(name))
|
||||
cg.add(cg.LineComment(f"{name}:"))
|
||||
if comp.config_schema is not None:
|
||||
conf_str = yaml_util.dump(conf)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# pylint: disable=unused-import
|
||||
from esphome.cpp_generator import ( # noqa: F401
|
||||
ArrayInitializer,
|
||||
ComponentMarker,
|
||||
Expression,
|
||||
FlashStringLiteral,
|
||||
LineComment,
|
||||
|
||||
@@ -531,6 +531,39 @@ class Library:
|
||||
return self
|
||||
|
||||
|
||||
def _wrap_in_noinline_iifes(lines: list[str], max_statements: int) -> list[str]:
|
||||
"""Wrap ``lines`` in one or more ``[]() [[gnu::noinline]] { ... }();`` IIFEs.
|
||||
|
||||
Splits into IIFEs of up to ``max_statements`` entries each. Never splits
|
||||
inside a brace-balanced block (e.g. the ``{`` / ``}`` pair that
|
||||
``cg.with_local_variable()`` emits around a scoped local), so an IIFE
|
||||
may exceed ``max_statements`` when a block straddles the boundary.
|
||||
"""
|
||||
out: list[str] = []
|
||||
chunk: list[str] = []
|
||||
depth = 0
|
||||
|
||||
def flush() -> None:
|
||||
if not chunk:
|
||||
return
|
||||
out.append("[]() [[gnu::noinline]] {")
|
||||
out.extend(chunk)
|
||||
out.append("}();")
|
||||
chunk.clear()
|
||||
|
||||
for line in lines:
|
||||
chunk.append(line)
|
||||
stripped = line.strip()
|
||||
if stripped == "{":
|
||||
depth += 1
|
||||
elif stripped == "}":
|
||||
depth -= 1
|
||||
if depth == 0 and len(chunk) >= max_statements:
|
||||
flush()
|
||||
flush()
|
||||
return out
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class EsphomeCore:
|
||||
def __init__(self):
|
||||
@@ -1003,14 +1036,32 @@ class EsphomeCore:
|
||||
|
||||
@property
|
||||
def cpp_main_section(self):
|
||||
from esphome.cpp_generator import statement
|
||||
from esphome.cpp_generator import ComponentMarker, statement
|
||||
|
||||
main_code = []
|
||||
# Split main_statements at ComponentMarker sentinels into a prefix
|
||||
# (statements emitted before any component) plus per-component groups.
|
||||
prefix: list[str] = []
|
||||
components: list[list[str]] = []
|
||||
current = prefix
|
||||
for exp in self.main_statements:
|
||||
text = str(statement(exp))
|
||||
text = text.rstrip()
|
||||
main_code.append(text)
|
||||
return "\n".join(main_code) + "\n\n"
|
||||
if isinstance(exp, ComponentMarker):
|
||||
current = [str(exp).rstrip()]
|
||||
components.append(current)
|
||||
continue
|
||||
current.append(str(statement(exp)).rstrip())
|
||||
|
||||
# No components → flat output (host build, tests).
|
||||
if not components:
|
||||
return "\n".join(prefix) + "\n\n"
|
||||
|
||||
# Each component's block is wrapped in a noinline IIFE lambda so its
|
||||
# stack frame is released on return, bounding peak stack during
|
||||
# setup(). Large blocks are sub-split to cap single heavy components
|
||||
# (e.g. sensor platforms with many filter registrations).
|
||||
pieces = list(prefix)
|
||||
for block in components:
|
||||
pieces.extend(_wrap_in_noinline_iifes(block, max_statements=50))
|
||||
return "\n".join(pieces) + "\n\n"
|
||||
|
||||
@property
|
||||
def cpp_global_section(self):
|
||||
|
||||
@@ -434,6 +434,21 @@ class LineComment(Statement):
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
class ComponentMarker(Statement):
|
||||
"""Sentinel marker recorded in main_statements when a component's
|
||||
to_code begins emitting code. Used by cpp_main_section to split
|
||||
setup() output into per-component chunks, so each component's
|
||||
stack frame is released on return."""
|
||||
|
||||
__slots__ = ("name",)
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
def __str__(self):
|
||||
return f"// === {self.name} ==="
|
||||
|
||||
|
||||
class ProgmemAssignmentExpression(AssignmentExpression):
|
||||
__slots__ = ()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from strategies import mac_addr_strings
|
||||
|
||||
from esphome import const, core
|
||||
from esphome.cpp_generator import ComponentMarker, RawStatement
|
||||
|
||||
|
||||
class TestHexInt:
|
||||
@@ -867,3 +868,102 @@ class TestEsphomeCore:
|
||||
mock_enable.assert_called_once_with("Wire")
|
||||
|
||||
assert "Wire" in target.platformio_libraries
|
||||
|
||||
|
||||
def test_wrap_in_noinline_iifes_empty_input() -> None:
|
||||
assert not core._wrap_in_noinline_iifes([], max_statements=10)
|
||||
|
||||
|
||||
def test_wrap_in_noinline_iifes_fewer_lines_than_limit() -> None:
|
||||
lines = ["a();", "b();", "c();"]
|
||||
assert core._wrap_in_noinline_iifes(lines, max_statements=10) == [
|
||||
"[]() [[gnu::noinline]] {",
|
||||
"a();",
|
||||
"b();",
|
||||
"c();",
|
||||
"}();",
|
||||
]
|
||||
|
||||
|
||||
def test_wrap_in_noinline_iifes_splits_at_max_statements() -> None:
|
||||
lines = [f"s{i}();" for i in range(5)]
|
||||
result = core._wrap_in_noinline_iifes(lines, max_statements=2)
|
||||
# With max=2 and 5 lines: chunks of 2, 2, 1 → 3 IIFEs.
|
||||
assert sum(1 for line in result if line.startswith("[]()")) == 3
|
||||
|
||||
|
||||
def test_wrap_in_noinline_iifes_never_splits_inside_braces() -> None:
|
||||
# max=2 would naively split after "{" but brace guard keeps block whole.
|
||||
lines = ["a();", "{", "inner();", "}", "b();"]
|
||||
assert core._wrap_in_noinline_iifes(lines, max_statements=2) == [
|
||||
"[]() [[gnu::noinline]] {",
|
||||
"a();",
|
||||
"{",
|
||||
"inner();",
|
||||
"}",
|
||||
"}();",
|
||||
"[]() [[gnu::noinline]] {",
|
||||
"b();",
|
||||
"}();",
|
||||
]
|
||||
|
||||
|
||||
def test_wrap_in_noinline_iifes_nested_braces() -> None:
|
||||
lines = ["{", "{", "deep();", "}", "}", "after();"]
|
||||
assert core._wrap_in_noinline_iifes(lines, max_statements=1) == [
|
||||
"[]() [[gnu::noinline]] {",
|
||||
"{",
|
||||
"{",
|
||||
"deep();",
|
||||
"}",
|
||||
"}",
|
||||
"}();",
|
||||
"[]() [[gnu::noinline]] {",
|
||||
"after();",
|
||||
"}();",
|
||||
]
|
||||
|
||||
|
||||
def test_wrap_in_noinline_iifes_unbalanced_braces_fall_through() -> None:
|
||||
# Pathological input where "}" appears before "{": don't crash; emit
|
||||
# a single IIFE with all lines rather than splitting mid-flight.
|
||||
lines = ["a();", "}", "b();"]
|
||||
result = core._wrap_in_noinline_iifes(lines, max_statements=1)
|
||||
assert result[0] == "[]() [[gnu::noinline]] {"
|
||||
assert result[-1] == "}();"
|
||||
assert [line for line in result if line in lines] == lines
|
||||
|
||||
|
||||
def test_cpp_main_section_no_components_emits_flat() -> None:
|
||||
target = core.EsphomeCore()
|
||||
target.main_statements = [RawStatement("a();"), RawStatement("b();")]
|
||||
out = target.cpp_main_section
|
||||
assert "[[gnu::noinline]]" not in out
|
||||
assert "a();" in out
|
||||
assert "b();" in out
|
||||
|
||||
|
||||
def test_cpp_main_section_component_marker_wraps_in_iife() -> None:
|
||||
target = core.EsphomeCore()
|
||||
target.main_statements = [
|
||||
ComponentMarker("logger"),
|
||||
RawStatement("new_logger();"),
|
||||
ComponentMarker("wifi"),
|
||||
RawStatement("new_wifi();"),
|
||||
]
|
||||
out = target.cpp_main_section
|
||||
assert out.count("[]() [[gnu::noinline]] {") == 2
|
||||
assert out.count("}();") == 2
|
||||
assert "// === logger ===" in out
|
||||
assert "// === wifi ===" in out
|
||||
|
||||
|
||||
def test_cpp_main_section_prefix_statements_stay_outside_iife() -> None:
|
||||
target = core.EsphomeCore()
|
||||
target.main_statements = [
|
||||
RawStatement("prefix();"),
|
||||
ComponentMarker("c"),
|
||||
RawStatement("body();"),
|
||||
]
|
||||
out = target.cpp_main_section
|
||||
assert out.index("prefix();") < out.index("[]() [[gnu::noinline]] {")
|
||||
|
||||
Reference in New Issue
Block a user