From 744f11fd636af03cdde2ef1de41abe9fe56aeff7 Mon Sep 17 00:00:00 2001 From: "Ivan Grokhotkov (bot)" Date: Wed, 15 Jul 2026 11:20:13 +0200 Subject: [PATCH] feat(examples): add cache access counters example --- examples/system/.build-test-rules.yml | 16 ++ examples/system/cache_counters/CMakeLists.txt | 8 + examples/system/cache_counters/README.md | 69 ++++++++ .../system/cache_counters/main/CMakeLists.txt | 3 + .../main/cache_counters_example_main.c | 114 ++++++++++++++ .../cache_counters/pytest_cache_counters.py | 147 ++++++++++++++++++ .../cache_counters/sdkconfig.ci.default | 1 + .../system/cache_counters/sdkconfig.ci.psram | 1 + .../system/cache_counters/sdkconfig.defaults | 2 + .../cache_counters/sdkconfig.defaults.esp32p4 | 2 + 10 files changed, 363 insertions(+) create mode 100644 examples/system/cache_counters/CMakeLists.txt create mode 100644 examples/system/cache_counters/README.md create mode 100644 examples/system/cache_counters/main/CMakeLists.txt create mode 100644 examples/system/cache_counters/main/cache_counters_example_main.c create mode 100644 examples/system/cache_counters/pytest_cache_counters.py create mode 100644 examples/system/cache_counters/sdkconfig.ci.default create mode 100644 examples/system/cache_counters/sdkconfig.ci.psram create mode 100644 examples/system/cache_counters/sdkconfig.defaults create mode 100644 examples/system/cache_counters/sdkconfig.defaults.esp32p4 diff --git a/examples/system/.build-test-rules.yml b/examples/system/.build-test-rules.yml index 9c1a842962b..49f193284d7 100644 --- a/examples/system/.build-test-rules.yml +++ b/examples/system/.build-test-rules.yml @@ -5,6 +5,22 @@ examples/system/base_mac_address: - *common_components - esp_hw_support +examples/system/cache_counters: + enable: + - if: SOC_CACHE_CNT_SUPPORTED == 1 + disable: + - if: CONFIG_NAME == "psram" and SOC_SPIRAM_SUPPORTED != 1 + disable_test: + - if: IDF_TARGET == "esp32h21" + temporary: true + reason: lack of runners + - if: CONFIG_NAME == "default" and SOC_SPIRAM_SUPPORTED == 1 + reason: the psram config subsumes the default config + depends_components: + - esp_hw_support + - hal + - soc + examples/system/deep_sleep: disable: - if: SOC_DEEP_SLEEP_SUPPORTED != 1 diff --git a/examples/system/cache_counters/CMakeLists.txt b/examples/system/cache_counters/CMakeLists.txt new file mode 100644 index 00000000000..825fe126e11 --- /dev/null +++ b/examples/system/cache_counters/CMakeLists.txt @@ -0,0 +1,8 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.22) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +# "Trim" the build. Include the minimal set of components, main, and anything it depends on. +idf_build_set_property(MINIMAL_BUILD ON) +project(cache_counters) diff --git a/examples/system/cache_counters/README.md b/examples/system/cache_counters/README.md new file mode 100644 index 00000000000..f9b86e18710 --- /dev/null +++ b/examples/system/cache_counters/README.md @@ -0,0 +1,69 @@ +| Supported Targets | ESP32-C5 | ESP32-C6 | ESP32-C61 | ESP32-H2 | ESP32-H21 | ESP32-H4 | ESP32-P4 | ESP32-S31 | +| ----------------- | -------- | -------- | --------- | -------- | --------- | -------- | -------- | --------- | + +# Cache Access Counters Example + +(See the README.md file in the upper level 'examples' directory for more information about examples.) + +This example shows how to measure cache hit/miss statistics using the cache access counters present on chips with `SOC_CACHE_CNT_SUPPORTED`, via the `esp_cache_cnt.h` API (`esp_hw_support` component). + +The API is chip-agnostic. Each chip defines a list of counter "units" — one unit is the set of counters observing one traffic stream, for example instruction fetches from core 0 into the L1 cache. The typical flow, which this example follows, is: + +1. `esp_cache_cnt_start()` — clear and enable all counters. +2. Run the code to be measured. +3. `esp_cache_cnt_stop()` — disable the counters, so that reading out and reporting the results is not counted as well. +4. `esp_cache_cnt_dump(NULL)` — print a table of all counter values; or, for programmatic access, enumerate the units with `esp_cache_cnt_num_units()` / `esp_cache_cnt_get_unit_info()` and read them with `esp_cache_cnt_get()`. + +For each unit, the hardware counts completed accesses, miss stall events, requester conflicts, and lines transferred to/from the next level of the memory hierarchy. The number of line fills is the true miss count, so the miss ratio of a unit is `line_fills / accesses` (available as `esp_cache_cnt_miss_ratio()`). See the `esp_cache_cnt.h` documentation for the exact semantics of each counter. + +The example runs the same read loop over buffers with different sizes and placements, and prints the counter values after each run. The working set determines which level of the memory hierarchy serves the accesses, which is visible in the counter values. The set of workloads depends on the target. + +## How to use example + +### Hardware Required + +Any development board with a supported chip. To run the PSRAM workloads, the board must have PSRAM. + +### Build and Flash + +``` +idf.py set-target +idf.py -p PORT flash monitor +``` + +(To exit the serial monitor, type ``Ctrl-]``.) + +## Example Output + +Output for the ESP32-P4 (a chip with a two-level cache hierarchy): + +``` +Workload: flash rodata, 128 KB x 10 passes +unit accesses line fills writebacks conflicts miss rate +l1-icache-core0 132724 45 - 0 0.03% +l1-icache-core1 2792 13 - 0 0.47% +l1-dcache-core0 20714 20481 307 7 98.88% +l1-dcache-core1 374 25 0 24606 6.68% +l2-cache-inst 8 1 - 0 12.50% +l2-cache-data 40960 1025 0 0 2.50% + +Workload: PSRAM, 192 KB x 20 passes (fits in L2) +unit accesses line fills writebacks conflicts miss rate +l1-icache-core0 379266 24 - 0 0.01% +l1-icache-core1 0 0 - 0 0.00% +l1-dcache-core0 61674 60747 263 0 98.50% +l1-dcache-core1 0 0 0 0 0.00% +l2-cache-inst 3 1 - 0 33.33% +l2-cache-data 121880 1152 1152 0 0.95% + +Workload: PSRAM, 2 MB x 5 passes (exceeds L2) +unit accesses line fills writebacks conflicts miss rate +l1-icache-core0 1107734 4 - 0 0.00% +l1-icache-core1 9302 25 - 0 0.27% +l1-dcache-core0 164746 163921 167 154 99.50% +l1-dcache-core1 1870 125 0 10600 6.68% +l2-cache-inst 2 0 - 0 0.00% +l2-cache-data 327680 80384 2481 0 24.53% + +Cache counters example done +``` diff --git a/examples/system/cache_counters/main/CMakeLists.txt b/examples/system/cache_counters/main/CMakeLists.txt new file mode 100644 index 00000000000..f95b50ea1a6 --- /dev/null +++ b/examples/system/cache_counters/main/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS "cache_counters_example_main.c" + PRIV_REQUIRES esp_psram + INCLUDE_DIRS "") diff --git a/examples/system/cache_counters/main/cache_counters_example_main.c b/examples/system/cache_counters/main/cache_counters_example_main.c new file mode 100644 index 00000000000..008a335f6be --- /dev/null +++ b/examples/system/cache_counters/main/cache_counters_example_main.c @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + */ + +/* + * Cache access counters example. + * + * Runs several memory access workloads with a known cache footprint and uses + * the esp_cache_cnt API to show how the cache responds. Which workloads run + * depends on the target: reading flash goes through the cache on every chip, + * internal SRAM only on chips with SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE, and + * the PSRAM workloads need PSRAM. Where there are two cache levels, the PSRAM + * working sets are sized against both of them; where there is one, against + * that one cache. + */ + +#include +#include +#include +#include "sdkconfig.h" +#include "esp_err.h" +#include "esp_heap_caps.h" +#include "esp_memory_utils.h" +#include "esp_cache_cnt.h" +#include "hal/cache_hal.h" +#include "soc/soc_caps.h" + +/* Read-only data, so it is placed in .rodata and read from flash through the + * cache. Larger than the data cache of any current chip, so that every pass + * has to fetch the lines from flash again. */ +#define FLASH_BUF_SIZE (128 * 1024) +static const uint32_t s_flash_buf[FLASH_BUF_SIZE / sizeof(uint32_t)]; + +/* Read one word from every cache line of the buffer, 'passes' times */ +static uint32_t read_workload(const uint32_t *buf, size_t size_bytes, int passes) +{ + const size_t line_size = cache_hal_get_cache_line_size(1, CACHE_TYPE_DATA); + const size_t words_per_line = line_size / sizeof(uint32_t); + const volatile uint32_t *p = buf; + uint32_t acc = 0; + for (int pass = 0; pass < passes; pass++) { + for (size_t i = 0; i < size_bytes / sizeof(uint32_t); i += words_per_line) { + acc += p[i]; + } + } + return acc; +} + +/* Counting is stopped before the results are printed, so that the printing + * itself does not show up in the counters. */ +static void run_phase(const char *name, const uint32_t *buf, size_t size, int passes) +{ + ESP_ERROR_CHECK(esp_cache_cnt_start()); + read_workload(buf, size, passes); + ESP_ERROR_CHECK(esp_cache_cnt_stop()); + + printf("Workload: %s\n", name); + ESP_ERROR_CHECK(esp_cache_cnt_dump(NULL)); + printf("\n"); +} + +void app_main(void) +{ + /* Reading data out of flash always goes through the cache, on every chip. */ + assert(esp_ptr_in_drom(s_flash_buf)); + run_phase("flash rodata, 128 KB x 10 passes", s_flash_buf, FLASH_BUF_SIZE, 10); + +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE + /* Only on chips which have a cache in front of internal memory. Elsewhere + * the CPU reaches internal SRAM directly and this workload would produce + * no data cache traffic at all. */ + const size_t internal_size = 8 * 1024; + uint32_t *internal_buf = heap_caps_malloc(internal_size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + assert(internal_buf != NULL); + memset(internal_buf, 0xA5, internal_size); + run_phase("internal SRAM, 8 KB x 1000 passes", internal_buf, internal_size, 1000); + free(internal_buf); +#endif + +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE && CONFIG_SPIRAM + /* One PSRAM allocation, used with three different working set sizes: + * smaller than the L1 data cache (64 KB), between L1 and L2 size, + * and larger than the L2 cache. */ + const size_t small_size = 48 * 1024; + const size_t mid_size = CONFIG_CACHE_L2_CACHE_SIZE * 3 / 8; + const size_t big_size = CONFIG_CACHE_L2_CACHE_SIZE * 4; + + uint32_t *psram_buf = heap_caps_malloc(big_size, MALLOC_CAP_SPIRAM); + assert(psram_buf != NULL); + memset(psram_buf, 0x5A, big_size); + + run_phase("PSRAM, 48 KB x 100 passes (fits in L1)", psram_buf, small_size, 100); + run_phase("PSRAM, 192 KB x 20 passes (fits in L2)", psram_buf, mid_size, 20); + run_phase("PSRAM, 2 MB x 5 passes (exceeds L2)", psram_buf, big_size, 5); + free(psram_buf); +#elif CONFIG_SPIRAM + /* Single cache level: one working set smaller than the cache and one + * larger than it. */ + const size_t small_size = 8 * 1024; + const size_t big_size = 512 * 1024; + + uint32_t *psram_buf = heap_caps_malloc(big_size, MALLOC_CAP_SPIRAM); + assert(psram_buf != NULL); + memset(psram_buf, 0x5A, big_size); + + run_phase("PSRAM, 8 KB x 500 passes (fits in cache)", psram_buf, small_size, 500); + run_phase("PSRAM, 512 KB x 5 passes (exceeds cache)", psram_buf, big_size, 5); + free(psram_buf); +#endif + + printf("Cache counters example done\n"); +} diff --git a/examples/system/cache_counters/pytest_cache_counters.py b/examples/system/cache_counters/pytest_cache_counters.py new file mode 100644 index 00000000000..7aa3170b542 --- /dev/null +++ b/examples/system/cache_counters/pytest_cache_counters.py @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: CC0-1.0 +import pytest +from pytest_embedded import Dut +from pytest_embedded_idf.utils import idf_parametrize + +# Names of the units observing core 0 instruction and data traffic, per target +INST_UNIT = { + 'esp32c5': 'l1-cache-ibus', + 'esp32c6': 'l1-cache-ibus', + 'esp32c61': 'l1-cache-ibus', + 'esp32h2': 'l1-cache-ibus', + 'esp32h4': 'l1-cache-inst-core0', + 'esp32p4': 'l1-icache-core0', + 'esp32s31': 'l1-cache-inst-core0', +} +DATA_UNIT = { + 'esp32c5': 'l1-cache-dbus', + 'esp32c6': 'l1-cache-dbus', + 'esp32c61': 'l1-cache-dbus', + 'esp32h2': 'l1-cache-dbus', + 'esp32h4': 'l1-cache-data-core0', + 'esp32p4': 'l1-dcache-core0', + 'esp32s31': 'l1-cache-data-core0', +} + +# The one workload every target runs. Reading .rodata goes through the cache on +# every chip, and 128 KB exceeds the data cache everywhere, so each pass has to +# fetch the lines from flash again. +FLASH_PHASE = 'flash rodata, 128 KB x 10 passes' + + +def expect_counter_row(dut: Dut, unit: str) -> dict: + """Match one row of the esp_cache_cnt_dump() table, return the counter values. + + Counters which the unit does not provide (printed as '-') are returned as None. + """ + num = r'(\d+|-)' + match = dut.expect(rf'{unit} +{num} +{num} +{num} +{num} +[0-9.]+%') + + def field(idx: int) -> int | None: + value = match.group(idx).decode() + return None if value == '-' else int(value) + + return { + 'accesses': field(1), + 'line_fills': field(2), + 'writebacks': field(3), + 'conflicts': field(4), + } + + +def check_phase(dut: Dut, workload: str, nonzero: dict) -> None: + """Expect one 'Workload: ...' line followed by a counter table. + + nonzero maps unit names (in dump order) to the list of counters which + must read non-zero after the workload. + """ + dut.expect_exact(f'Workload: {workload}') + for unit, counters in nonzero.items(): + row = expect_counter_row(dut, unit) + for counter in counters: + assert row[counter], f'{workload}: expected {unit} {counter} > 0, got {row[counter]}' + + +@pytest.mark.generic +@idf_parametrize('config', ['default'], indirect=['config']) +@idf_parametrize('target', ['esp32c6', 'esp32h2'], indirect=['target']) +def test_cache_counters(dut: Dut) -> None: + # No PSRAM: only the flash workload runs. Its code is fetched from flash as + # well, so the instruction unit counts accesses too. + check_phase( + dut, + FLASH_PHASE, + { + INST_UNIT[dut.target]: ['accesses'], + DATA_UNIT[dut.target]: ['accesses', 'line_fills'], + }, + ) + dut.expect_exact('Cache counters example done') + + +@pytest.mark.generic +@idf_parametrize('config', ['psram'], indirect=['config']) +@idf_parametrize('target', ['esp32c5', 'esp32c61', 'esp32h4', 'esp32s31'], indirect=['target']) +def test_cache_counters_psram(dut: Dut) -> None: + inst = INST_UNIT[dut.target] + data = DATA_UNIT[dut.target] + check_phase(dut, FLASH_PHASE, {inst: ['accesses'], data: ['accesses', 'line_fills']}) + check_phase(dut, 'PSRAM, 8 KB x 500 passes (fits in cache)', {inst: ['accesses'], data: ['accesses']}) + # A working set larger than the cache must be fetched from PSRAM on every pass + check_phase(dut, 'PSRAM, 512 KB x 5 passes (exceeds cache)', {inst: ['accesses'], data: ['accesses', 'line_fills']}) + dut.expect_exact('Cache counters example done') + + +@pytest.mark.generic +@idf_parametrize('config', ['psram'], indirect=['config']) +@idf_parametrize('target', ['esp32p4'], indirect=['target']) +def test_cache_counters_psram_esp32p4(dut: Dut) -> None: + # 128 KB is larger than the L1 data cache but fits in L2, so L1 refills on + # every pass while L2 only has to fill once + check_phase( + dut, + FLASH_PHASE, + { + 'l1-icache-core0': ['accesses'], + 'l1-dcache-core0': ['accesses', 'line_fills'], + 'l2-cache-data': ['accesses'], + }, + ) + # Internal SRAM is reached through the L1 cache on this chip, and the whole + # working set stays in it, so the L1 data unit counts accesses but no fills + check_phase( + dut, + 'internal SRAM, 8 KB x 1000 passes', + { + 'l1-icache-core0': ['accesses'], + 'l1-dcache-core0': ['accesses'], + }, + ) + check_phase( + dut, + 'PSRAM, 48 KB x 100 passes (fits in L1)', + { + 'l1-icache-core0': ['accesses'], + 'l1-dcache-core0': ['accesses'], + }, + ) + # Larger than the L1 data cache: every pass refills L1 from L2 + check_phase( + dut, + 'PSRAM, 192 KB x 20 passes (fits in L2)', + { + 'l1-dcache-core0': ['accesses', 'line_fills'], + 'l2-cache-data': ['accesses'], + }, + ) + # Larger than the L2 cache: lines are continuously fetched from PSRAM + check_phase( + dut, + 'PSRAM, 2 MB x 5 passes (exceeds L2)', + { + 'l1-dcache-core0': ['accesses', 'line_fills'], + 'l2-cache-data': ['accesses', 'line_fills'], + }, + ) + dut.expect_exact('Cache counters example done') diff --git a/examples/system/cache_counters/sdkconfig.ci.default b/examples/system/cache_counters/sdkconfig.ci.default new file mode 100644 index 00000000000..719f3a07cb3 --- /dev/null +++ b/examples/system/cache_counters/sdkconfig.ci.default @@ -0,0 +1 @@ +# Build with sdkconfig.defaults only (no PSRAM) diff --git a/examples/system/cache_counters/sdkconfig.ci.psram b/examples/system/cache_counters/sdkconfig.ci.psram new file mode 100644 index 00000000000..cc641ea6033 --- /dev/null +++ b/examples/system/cache_counters/sdkconfig.ci.psram @@ -0,0 +1 @@ +CONFIG_SPIRAM=y diff --git a/examples/system/cache_counters/sdkconfig.defaults b/examples/system/cache_counters/sdkconfig.defaults new file mode 100644 index 00000000000..1fa1f49cef5 --- /dev/null +++ b/examples/system/cache_counters/sdkconfig.defaults @@ -0,0 +1,2 @@ +# No target-independent default options; per-target options are in +# sdkconfig.defaults.. This file must exist for those to be applied. diff --git a/examples/system/cache_counters/sdkconfig.defaults.esp32p4 b/examples/system/cache_counters/sdkconfig.defaults.esp32p4 new file mode 100644 index 00000000000..d519bfbf2b8 --- /dev/null +++ b/examples/system/cache_counters/sdkconfig.defaults.esp32p4 @@ -0,0 +1,2 @@ +CONFIG_CACHE_L2_CACHE_512KB=y +CONFIG_CACHE_L2_CACHE_LINE_128B=y