feat(examples): add cache access counters example

This commit is contained in:
Ivan Grokhotkov (bot)
2026-07-15 11:20:13 +02:00
committed by Ivan Grokhotkov
parent 16078650c8
commit 744f11fd63
10 changed files with 363 additions and 0 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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 <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
```

View File

@@ -0,0 +1,3 @@
idf_component_register(SRCS "cache_counters_example_main.c"
PRIV_REQUIRES esp_psram
INCLUDE_DIRS "")

View File

@@ -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 <stdio.h>
#include <string.h>
#include <assert.h>
#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");
}

View File

@@ -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')

View File

@@ -0,0 +1 @@
# Build with sdkconfig.defaults only (no PSRAM)

View File

@@ -0,0 +1 @@
CONFIG_SPIRAM=y

View File

@@ -0,0 +1,2 @@
# No target-independent default options; per-target options are in
# sdkconfig.defaults.<target>. This file must exist for those to be applied.

View File

@@ -0,0 +1,2 @@
CONFIG_CACHE_L2_CACHE_512KB=y
CONFIG_CACHE_L2_CACHE_LINE_128B=y