[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:
J. Nick Koston
2026-04-17 15:06:41 -05:00
parent 70ea527161
commit 6b67224286
5 changed files with 174 additions and 6 deletions
+100
View File
@@ -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]] {")