ESP-32 Fast Memory Copy

This article demonstrates how to copy memory fast on Xtensa (ESP-32). It analyzes the Xtensa CPU architecture, the memory layout, and instruction support. Benchmarking shows the custom fast copy routine for aligned data copy in Assembly is about 10% faster than stdlib provided in ROM.

--by Captdam @ Oct 13, 2026 Oct 13, 2026

Index

I am recently working on a ESP-32 project that serves static files via HTTP using bare TCP. That means, the server does one job, copy data from flash or RAM to TCP buffer.

In my previous articles, ESP-32 TCP Baremetal HTTP server and String Comparison Micro-optimization on X86, ARM and Xtensa, I had discussed how to build a simple ESP-32 HTTP server based on TCP and how to micro-optimization for string operation on different CPU architectures. You may want to have a look into these two articles because it includes some basic knowledge that I am gonna use in this article.

In this article, I will discuss how to move data efficiently on Extensa (the CPU used inside ESP-32). More specifically:

In this article, we will be referring two documents:

Memory Model

Harvard Architecture

System Address Mapping
System Address Mapping - ESP32 TRM (Version 5.8) screenshot
Address Mapping
Address Mapping - ESP32 TRM (Version 5.8) screenshot

Xtensa is a dual-core Harvard architecture CPU with 32-bit address space. This means, two separate memory buses are used for data and instructions. The ESP-32 document states:

Violating the instruction / data access pattern results in CPU panic. The program will halt.

Although the address of the memory bus is 32-bit wide, allowing 4GB of space, some regions are reserved (not connected), some regions are mapped to the same physical memory.

For example, the figure shows that the internal SRAM is mapped to both data memory space at address 0x3FFAE000 to 0x3FFFFFFF and instruction memory space at address 0x40070000 to 0x400BFFFF.

This allows us to store data in instruction space. When a program is initialized, the program text (including program instructions and initialized data) can be loaded as a whole unit into a SRAM region that is mapped to both instruction space (for instruction fetch) and data space (for data read and write). Otherwise, we have to load the instruction and data separately into instruction space and data space.

Memory Types

ESP-32 comes with different types of physical memories.

RAM

Embedded Memory Address Mapping
Embedded Memory Address Mapping - ESP32 TRM (Version 5.8) screenshot

RAM, more specifically, internal SRAM. This is our main memory. This memory is directly connected to the CPU's memory bus. Any run-time data, including heap and stack, global (static) variables are stored in RAM. The program must be loaded from flash (or any other external device) into the RAM before its instruction can be fetched and executed by the CPU, and its data can be read or written by the CPU.

Xtensa uses Harvard architecture. The entire RAM is mapped to the data space, but only a portion of the RAM is mapped to the instruction space. The program instruction and the data must be placed into the dedicated memory region and follow the correct access pattern. Otherwise, the CPU will panic.

The RAM can be divided into 3 portions:

Access (read, write, fetch) to RAM takes only one CPU cycle, unless stalled.

RAM is made from static RAM, or SRAM. This is an electrical engineering term, meaning it is made from static electronic D-type registers. No capacitor refresh need, as opposed to DRAM. Don't be confused with programming language term for static variable.

Instruction Memory Access
Instruction Memory Access - Xtensa ISA screenshot
Performance of L32R Instruction
Performance of l32 Instruction - Xtensa ISA screenshot

The Xtensa document says: Only the loads L32I, L32I.N, and L32R can access InstRAM and InstROM locations and Only the stores S32I and S32I.N can access InstRAM.

We have to use l32 instruction to access instruction space. Furthermore, it is slower to access instruction space than data space. I cannot find the actual clock cycle to access instruction space and the reason of the slower speed, I guess it is because the instruction space is being accessed by the instruction fetch and data read at the same time, hence requires more cycles.

ROM

ROM is an internal read-only memory. As its name suggests, content is burned in the factory and we cannot modify it (unless you have 1 million dollars so you can order a custom batch). ROM provides 1st stage boot loader and some useful functions, including the standard C library, with memcpy().

The ROM can be divided into 2 portions:

Access ROM takes only one CPU cycle, unless stalled.

Flash

External Memory Address Mapping
External Memory Address Mapping - ESP32 TRM (Version 5.8) screenshot

Flash is where our program (including program instructions and initialized data) are stored. This memory is non-volatile, meaning its content remains the same after power off.

ESP32 Series Comparison
ESP32 Series Comparison - ESP32 Series Datasheet v5.3 screenshot
ESP32-PICO Series Comparison
ESP32-PICO Series Comparison - ESP32-PICO Series Datasheet v1.3 screenshot

Flash can be external to the chip, connected to the chip via PCB (like ESP32-D0); or internal to the chip but external to the CPU wafer, connected to the CPU wafer via metal layers (like ESP32-PICO and ESP32-U4). Most importantly, flash is NOT DIRECTLY connected to the CPU's memory bus. The CPU cannot fetch instructions on flash, nor read or write data on flash.

ESP-32 supports up to 16MB of external memories (flash and RAM), which is sufficient to store a huge amount of instruction and data (for an embedded system), unless you want to store photos or movies (but who on earth does that, get an external SD card for it).

We can map:

into
 SPI Architecture
SPI Architecture - ESP32 TRM (Version 5.8) screenshot

ESP-32 supports SPI (DSPI, QSPI) external memories. There are 4 SPI peripherals on ESP-32, SPI0 is used to access external memories.

Cache

Since flash and external RAM are not directly connected to the CPU's memory bus, the CPU cannot access them. One way is to manually load the content using SPI into the internal RAM, but this is low efficiently.

Cache system can be used to automatically perform the loading task in the background. The cache system maps parts of external SPI flash or external SPI RAM to its internal poll, by temporarily storing external content in an internal memory to allow access from internal devices. This allows the CPU to access external memory like it is internal.

Cache Block Diagram
Cache Block Diagram - ESP32 TRM (Version 5.8) screenshot

On ESP-32, there are two cache pools, each 32kB, made from 32 bytes long blocks.

Here is how it works. When the CPU wants something (read data or fetch instruction), it places the address on the memory bus. If that address belongs to an external device (for example, an instruction on flash), that request is routed to the cache system.

The cache system first checks if the required content is cached in the cache system's internal pool:

Data Allocation

Cache

It is fine to run programs on external flash, read data from external flash or external RAM. The cache system allows the CPU to access external memory like it is internal.

However, the cache pool is limited. It holds only two 32kB blocks of program text or data. That means, it is impossible to cache the entire external flash or external RAM. If the CPU tries to access some content from an external device that is not cached, an expensive cache miss happens.

For example, the CPU and RAM on ESP-32 is running at 160MHz, meaning accessing a 32-bit (a word) in-RAM or in-cache content takes only 1 clock cycle, or 6.25 nanoseconds. External Quad-SPI devices normally run at 40MHz (and if noisy or distance is long, the SPI clock frequency will be even lower), there will be 8 SPI cycles (4 bits per SPI cycle) to read a 32-bit content, or 200 nanoseconds. That is 32 times slower, not even considering other overheads like the SPI address phase.

Again, cache is limited. The content we just cached may be replaced by other content. When the same piece of content is required again but has been replaced, we must recache this content.

For less frequently used programs and data, or when the timing is not critical, it is OK to use cache. Cache miss is slow, other than that, cache is perfectly fine. Unless you are crazy with SPI clock noise or power consumption.

RAM

We can force some programs and data into the RAM. Therefore, access is always single cycle.

To do so, in C language with ESP-IDF, add the attribute IRAM_ATTR for program and constant data, DRAM_ATTR for data. Note, Xtensa uses Harvard architecture, IRAM_ATTR places content in Instruction memory, DRAM_ATTR places content in data memory:


DRAM_ATTR int globalStaticData_inDataRAM;

IRAM_ATTR int function_in() {

	IRAM_ATTR const static int localStaticData_inInstRAM = 1;
	DRAM_ATTR const static int localStaticData_inDataRAM = 2;

	int localData_inStack = 3; // Stack is always in RAM
	const static int localConstData_inTextFlash = 4;
}
	

During the program initialization, the in-RAM program and data will be copied from flash into RAM before invoke our main program.

However, RAM is limited. You cannot place all program text into RAM, only those frequently used or timing critical text should. For thoses not placed into RAM, we will let the cache system to load them on-the-fly. The cost is occasional stall for cache miss.

RAM is made from 32kB banks, accessing the same bank simultaneously from different upstream device will stall.

Benchmark Setup

In this article, we will write a simple program that copies data from one place (the source) to another (the destination):


#define MAXSIZE 2048

size_t len = 1024;
char dest[MAXSIZE];
char src[MAXSIZE];
memcpy(dest, src, len);
	

On a 32-bit platform, it is uncommon to see the compiler place variables on misaligned addresses. Aligned memory increases performance because it allows aligned multi-byte access. We can also add __attribute__((aligned(size))) attribute to ensure alignment.


char __attribute__((aligned(4))) dest[MAXSIZE];
char __attribute__((aligned(4))) src[MAXSIZE];
	

We will measure the CPU cycles between the start and end of the memory copy using the CPU cycle counter. To do so, we can use the uint32_t cpu_hal_get_cycle_count() macro provided by ESP-IDF, or directly read the CPU special register rsr.ccount ax in Assembly language.


#include "hal/cpu_hal.h"

uint32_t start = cpu_hal_get_cycle_count();
memcpy(dest, src, len);
uint32_t end = cpu_hal_get_cycle_count();
	

To print the cycle count in console:


#include "esp_log.h"

ESP_LOGI("[Copy]", "%x <-- %x", dest, src);
ESP_LOGI("[Copy]", "Cycles %d: %d", i, end - start);
	

To prevent program cache miss, eliminating any stall cycle contributed by the program instruction instead of the actual data to copy, we will place the program instruction into the RAM.


#include "esp_attr.h"

IRAM_ATTR app_main() {

}
	

The destination is always in RAM:

The source can be placed in different memory locations:

Now, put everything together:


#include "esp_log.h"
#include "esp_attr.h"
#include "hal/cpu_hal.h"

#include <string.h>

#define MAXSIZE 2048
#define LEN 1

DRAM_ATTR char __attribute__((aligned(4))) dest[MAXSIZE]; // Data RAM, static

IRAM_ATTR void app_main(void) {
	char __attribute__((aligned(4))) src[MAXSIZE]; // Data RAM, stack
//	const static char __attribute__((aligned(4))) src[MAXSIZE]; // Flash via cache
//	DRAM_ATTR const static char __attribute__((aligned(4))) src[MAXSIZE]; // Data RAM, static
//	IRAM_ATTR const static char __attribute__((aligned(4))) src[MAXSIZE]; // Instruction RAM
	
	ESP_LOGI("[Copy]", "%x <-- %x", dest, src);
	
	for (int i = 0; i < 5; i++) {
		uint32_t start = cpu_hal_get_cycle_count();

		memcpy(dest, src, LEN);
		
		uint32_t end = cpu_hal_get_cycle_count();
		ESP_LOGI("[Copy]", "Cycles %d: %d", i, end - start);
	}
}
	

In each case, we run for 5 times. The first run shows cycle count when the chache missed; hence, this count includes cycles to load content from external device. The following four runs show cycle count when cache hit.

Standard memcpy()

The on-chip ROM comes with a standard C library, including the memcpy() function. This function is placed in the ROM no matter if we used it or not, it is buried in the factory.

This function is stored at address 0x4000c2c8. For detail, you can disassemble the ROM ELF file tool/esp-rom-elfs/20240305/esp32_rev300_rom.elf.

I am not gonna walk through the disassembled machine code of this function. It is (somehow) optimized, for example, it copies 4 4-byte words at a time to reduce loop overhead.

You may choose to use or to not use this function. If you choose to use it, you don't need to write your own, which saves flash and instruction RAM space.

Benchmarking

Now, let's compile and load this program into ESP-32 for this benchmarking.

In all cases, since dest is declared as DRAM_ATTR as a global variable, its address is at 0x3FFB1E0C, which is in internal data memory space, target to internal SRAM2.

If the source is declared as const static, it will be placed in program text in flash:


const static char __attribute__((aligned(4))) src[MAXSIZE];
	

In this case, the address of src is at 0x3F403830, which is in external data space.

Cycle Count - Copy from Flash - stdlib
Length Run 1Run 2Run 3Run 4Run 5
1 67340404040
4 67542424242
70 198486868686
128 3274113113113113
1024 21530673673673673
2047 424071325132513251325
2048 423941313131313131313

If the source is declared without any attribute, it will be placed in the function stack, and stack can only be in data RAM:


char __attribute__((aligned(4))) src[MAXSIZE];
	

In this case, the address of src is at 0x3FFB43C0, which is in internal data space, target to internal SRAM2.

Cycle Count - Copy from Data RAM Stack - stdlib
Length Run 1Run 2Run 3Run 4Run 5
1 3939393939
4 4141414141
70 8585858585
128 112112112112112
1024 672672672672672
2047 13241324132413241324
2048 13121312131213121312

If the source is declared as DRAM_ATTR const static, it will be placed in data RAM:


DRAM_ATTR const static char __attribute__((aligned(4))) src[MAXSIZE];
	

In this case, the address of src is at 0x3FFB160C, which is in internal data space, target to internal SRAM2.

Cycle Count - Copy from Data RAM Static - stdlib
Length Run 1Run 2Run 3Run 4Run 5
1 3939393939
4 4141414141
70 8585858585
128 112112112112112
1024 672672672672672
2047 13241324132413241324
2048 13131313131313131313

If the source is declared as IRAM_ATTR const static, it will be placed in instruction RAM:


IRAM_ATTR const static char __attribute__((aligned(4))) src[MAXSIZE];
	

In this case, the address of src is at 0x400835C8, which is in internal instruction space, target to internal SRAM0.

Cycle Count - Copy from Instruction RAM - stdlib
Length Run 1Run 2Run 3Run 4Run 5
1 LoadStoreError
4 4848484848
70 LoadStoreError
128 337337337337337
1024 24652465246524652465
2047 LoadStoreError
2048 48974897489748974897

Conclusion

Cycle Count - Conclusion - stdlib
Length 1470128102420472048 Speed (cycle/byte @ 2048B)
Flash (Miss) 67367519843274215304240742394 20.700
Flash (Hit) 40428611367313251313 0.641
Data RAM (Stack) 39418511267213241312 0.641
Data RAM (Static) 39418511267213241313 0.641
Instruction RAM Error48Error3372465Error4897 2.391

The result shows:

Our Fast Copy for Aligned Data

As I discussed in my previous article, multi-byte access on word-aligned data gives better permanence than byte access.

That means, if we can copy 4 bytes (or a word, since a word on a 32-bit CPU is 4 bytes) at a time, it will be 4 times faster than copying 1 byte a time:


for (int i = 0; i < len / 4; i++) {
	*((uint32_t*)dest) = *((uint32_t*)src);
	dest += 4;
	src += 4;
}
	

Although the address of the two buffers are aligned, the length may not. Therefore, we still need to use byte copy for the last 1 to 3 bytes:


for (int i = 0; i < len % 4; i++) {
	*((char*)dest) = *((char*)src);
	dest++;
	src++;
}
	

For example, to copy "Hello memories!", which contains 15 characters (excluding the zero-terminator), we will copy:

  1. Hell - Word access

  2. o me - Word access

  3. mori - Word access

  4. e - Byte access

  5. s - Byte access

  6. ! - Byte access

Let's compile our code:


400835c4 <mycopy>:
400835c4:	004136        	entry	a1, 32
400835c7:	41b240        	srli	a11, a4, 2
400835ca:	fb9c      	beqz.n	a11, 400835ed <mycopy+0x29>
400835cc:	a0cb20        	addx4	a12, a11, a2
400835cf:	c08c20        	sub	a8, a12, a2
400835d2:	fcc882        	addi	a8, a8, -4
400835d5:	418280        	srli	a8, a8, 2
400835d8:	039d      	mov.n	a9, a3
400835da:	01c882        	addi	a8, a8, 1
400835dd:	078876        	loop	a8, 400835e8 <mycopy+0x24>
400835e0:	09a8      	l32i.n	a10, a9, 0
400835e2:	994b      	addi.n	a9, a9, 4
400835e4:	02a9      	s32i.n	a10, a2, 0
400835e6:	224b      	addi.n	a2, a2, 4
400835e8:	a03b30        	addx4	a3, a11, a3
400835eb:	0c2d      	mov.n	a2, a12
400835ed:	144040        	extui	a4, a4, 0, 2
400835f0:	d48c      	beqz.n	a4, 40083601 <mycopy+0x3d>
400835f2:	424a      	add.n	a4, a2, a4
400835f4:	000382        	l8ui	a8, a3, 0
400835f7:	331b      	addi.n	a3, a3, 1
400835f9:	004282        	s8i	a8, a2, 0
400835fc:	221b      	addi.n	a2, a2, 1
400835fe:	f29247        	bne	a2, a4, 400835f4 <mycopy+0x30>
40083601:	f01d      	retw.n0x33
	

To be honest, I cannot say I am satisfied with the generated code. I decided to write my own using inline assembly language.

Xtensa ISA

Before we start to create our assembly code, I want to highlight two instructions provided by Xtensa.

Loop

Xtensa provides a set of zero-overhead looping instructions: loop, loopnez and loopgtz:


my_loop:
	loopxxx, as, label_end
	inst1
	inst2
label_end:
	inst3
	

where:

There are 3 special registers in the CPU:

The loopxxx instruction can be used to reduce the loop overhead if the number of iteration is known before the loop starts. Without it, we will have to use additional instructions to keep track of number of iteration and do compare and branch out:


my_loop:
	inst1
	inst2
	addi	as, as, -1
	bnez	as, my_loop
label_end:
	inst3
	

Note there is only one set of loop registers. Therefore, nested loop is not allowed if using loop instruction for bother inner and outer loop, calling subroutine inside loop is risky because the subroutine may use loop instruction.

Harvard Architecture

Xtensa uses Harvard architecture, access pattern differes between the instruction space and the data space.

To allow read from instruction space, we must modify our code for copying the last few byte:


uint32_t last = *((uint32_t*)src);
	For (int i = 0; i < len % 4; i++) {
		*((char*)dest) = last;
		dest++;
		last >> 8;
}
	

In this way, we will use word read to read a whole word (including a few more extra bytes) from the source, just to satisfy the access pattern in the instruction space.

We will read a few extra bytes if the source's length is not a multiple of 4. This is fine, reading extra bytes has no side effects.

Furthermore, memory is assigned to our program by page, and page size is always multiple of 4. Therefore, reading a few extra bytes still fall within the same page, and it will not trigger an out-of-bounds memory error.

We use byte write to the destination so we don't accidentally write to other data. If the destination is in the instruction space, length must be multiple of 4.

Our Code

Let's create the function that write the last few bytes:


#define faststr_memcpy(dest, src, len) _faststr_memcpy((uint8_t*)dest, (uint8_t*)src, (size_t)len)
IRAM_ATTR void _faststr_memcpy(uint8_t* dest, uint8_t* src, size_t len) {
	asm volatile (
		"l32i	%[src], %[src], 0\n\t"
		"loopnez	%[len], 1f\n\t"
		"s8i	%[src], %[dest], 0\n\t"
		"srli	%[src], %[src], 8\n\t"
		"addi	%[dest], %[dest], 1\n\t"
		"1:	\n\t"
	:
		[src] "+a" (src),
		[dest] "+a" (dest)
	:
		[len] "a" (len)
	:
		"memory"
	);
}
		

At the beginning:

  • dest - The address of the last word in the destination buffer.

  • src - The address of the last word in the source buffer.

  • len - Number of bytes to copy in the last word, can be 0 to 3.

First, the content of the last word is loaded into src. It may include extra data.

If len is non-zero, load the loop counter, which is the number of bytes to copy in the last word. In the loop:

  1. Write the lower 8 bits in src (content) to dest (address).

  2. Shift src (content) right for 8 bits, so the next bytes goes to the lower 8-bit location.

  3. Incrment dest (address), so it points to the next byte in the destination.

At the end:

  • dest - The Address after the last byte in the destination.

  • src - Clobbed.

  • len - Not changed.

All three parameters are no longer required.

Now, the main portion that performs the word copy:


#define faststr_memcpy4(dest, src, len) _faststr_memcpy4((uint32_t*)dest, (uint32_t*)src, (size_t)len)
IRAM_ATTR void _faststr_memcpy4(uint32_t* dest, uint32_t* src, size_t len) {
	uint32_t next = len & 0b11;
	len /= 4;
	asm volatile (
		"loopnez	%[len], 1f\n\t"
		"l32i	%[len], %[src], 0\n\t"
		"s32i	%[len], %[dest], 0\n\t"
		"addi	%[src], %[src], 4\n\t"
		"addi	%[dest], %[dest], 4\n\t"
		"1:	\n\t"
	:
		[len] "+a" (len),
		[src] "+a" (src),
		[dest] "+a" (dest)
	:
	:
		"memory"
	);
	faststr_memcpy(dest, src, next);
}
		

At the beginning:

  • dest - The address of the destination buffer.

  • src - The address of the source buffer.

  • len - Number of bytes to copy.

First, find out:

  • next - Number of bytes to copy in the last word, can be 0 to 3.

  • len - Number of words to copy.

If len is non-zero, load the loop counter, which is the number of words to copy. In the loop:

  1. len no longer required, use this register as scratch register.

  2. Read a word from src (address) into len (scratch content).

  3. Write a word from len (scratch content) into dest (address).

  4. Incrment src (address) and dest (address) for 4 (word size).

For every 4 bytes, we need 1 cycle to read, 1 cycle to write, and 2 cycles to increment the two pointers. In conclusion, that is 4 cycles for every 4 bytes, or 1 cycle per byte. Here, the overhead is 2 cycles to increase the 2 pointers.

At the end:

  • dest - The address of the last word of the destination buffer.

  • src - The address of the last word of the source buffer.

  • len - Clobbed.

dest and src can be feed to faststr_memcpy(dest, src, len).

To minimize the overhead contributed by the pointer increment, we can perform multiple word read and write with incrementing offset, and then perform a combined pointer increment at the end of each iteration:


#define faststr_memcpy48(dest, src, len) _faststr_memcpy48((uint32_t*)dest, (uint32_t*)src, (size_t)len)
IRAM_ATTR void _faststr_memcpy48(uint32_t* dest, uint32_t* src, size_t len) {
	uint32_t next = len & 0b11111;
	len /= 32;
	asm volatile (
		"loopnez	%[len], 1f\n\t"
		"l32i	%[len], %[src], 0\n\t"
		"s32i	%[len], %[dest], 0\n\t"
		"l32i	%[len], %[src], 4\n\t"
		"s32i	%[len], %[dest], 4\n\t"
		"l32i	%[len], %[src], 8\n\t"
		"s32i	%[len], %[dest], 8\n\t"
		"l32i	%[len], %[src], 12\n\t"
		"s32i	%[len], %[dest], 12\n\t"
		"l32i	%[len], %[src], 16\n\t"
		"s32i	%[len], %[dest], 16\n\t"
		"l32i	%[len], %[src], 20\n\t"
		"s32i	%[len], %[dest], 20\n\t"
		"l32i	%[len], %[src], 24\n\t"
		"s32i	%[len], %[dest], 24\n\t"
		"l32i	%[len], %[src], 28\n\t"
		"s32i	%[len], %[dest], 28\n\t"
		"addi	%[src], %[src], 32\n\t"
		"addi	%[dest], %[dest], 32\n\t"
		"1:	\n\t"
	:
		[len] "+a" (len),
		[src] "+a" (src),
		[dest] "+a" (dest)
	:
	:
		"memory"
	);
	faststr_memcpy4(dest, src, next);
}
		

I decided to choose 32 bytes for a burst, because the cache block is 32 bytes long.

Program text inside the loop cannot exceed 256 bytes.

At the beginning:

  • dest - The address of the destination buffer.

  • src - The address of the source buffer.

  • len - Number of bytes to copy.

First, find out:

  • next - Number of bytes to copy in the last burst, can be 0 to 31.

  • len - Number of burst to copy.

If len is non-zero, load the loop counter, which is the number of burst to copy. In the loop:

  1. len no longer required, use this register as scratch register.

  2. Read a word from src (address) into len (scratch content).

  3. Write a word from len (scratch content) into dest (address).

  4. Read a word from src + 4 (address) into len (scratch content).

  5. Write a word from len (scratch content) into dest + 4 (address).

  6. Continue. Read and write for 32 (burst size) bytes in total.

  7. Incrment src (address) and dest (address) for 32.

For every 32 bytes, we need 8 cycle to read, 8 cycle to write, and 2 cycles to increment the two pointers. In conclusion, that is 18 cycles for every 32 bytes, or slight more than half cycle per byte.

At the end:

  • dest - The address of the last burst of the destination buffer.

  • src - The address of the last burst of the source buffer.

  • len - Clobbed.

dest and src can be feed to faststr_memcpy4(dest, src, len).

Benchmarking

Now, let's compile and load this program into ESP-32 for this benchmarking.


400835b8 <_faststr_memcpy48>:
400835b8:	004136        	entry	a1, 32
400835bb:	418540        	srli	a8, a4, 5
400835be:	259876        	loopnez	a8, 400835e7 <_faststr_memcpy48+0x2f>
400835c1:	0388      	l32i.n	a8, a3, 0
400835c3:	0289      	s32i.n	a8, a2, 0
400835c5:	1388      	l32i.n	a8, a3, 4
400835c7:	1289      	s32i.n	a8, a2, 4
400835c9:	2388      	l32i.n	a8, a3, 8
400835cb:	2289      	s32i.n	a8, a2, 8
400835cd:	3388      	l32i.n	a8, a3, 12
400835cf:	3289      	s32i.n	a8, a2, 12
400835d1:	4388      	l32i.n	a8, a3, 16
400835d3:	4289      	s32i.n	a8, a2, 16
400835d5:	5388      	l32i.n	a8, a3, 20
400835d7:	5289      	s32i.n	a8, a2, 20
400835d9:	6388      	l32i.n	a8, a3, 24
400835db:	6289      	s32i.n	a8, a2, 24
400835dd:	7388      	l32i.n	a8, a3, 28
400835df:	7289      	s32i.n	a8, a2, 28
400835e1:	20c332        	addi	a3, a3, 32
400835e4:	20c222        	addi	a2, a2, 32
400835e7:	248240        	extui	a8, a4, 2, 3
400835ea:	079876        	loopnez	a8, 400835f5 <_faststr_memcpy48+0x3d>
400835ed:	0388      	l32i.n	a8, a3, 0
400835ef:	0289      	s32i.n	a8, a2, 0
400835f1:	334b      	addi.n	a3, a3, 4
400835f3:	224b      	addi.n	a2, a2, 4
400835f5:	144040        	extui	a4, a4, 0, 2
400835f8:	0338      	l32i.n	a3, a3, 0
400835fa:	079476        	loopnez	a4, 40083605 <_faststr_memcpy48+0x4d>
400835fd:	004232        	s8i	a3, a2, 0
40083600:	413830        	srli	a3, a3, 8
40083603:	221b      	addi.n	a2, a2, 1
40083605:	f01d      	retw.n
		

Here is the machine code generated by the compiler, our code is placed at address 0x400835B8, which is in internal instruction memory space, target to internal SRAM0.

Furthermore, the compiler inlined the word copy subroutine _faststr_memcpy4 at address 0x400835EA and byte copy subroutine _faststr_memcpy at address 0x400835F8.

We will try both:

In all cases, since dest is declared as DRAM_ATTR as a global variable, its address is at 0x3FFB1E0C. (Same as when using stdlib)

If the source is declared as const static, its address is at 0x3F403830, which is in external data space. (Same as when using stdlib)

Cycle Count - Copy from Flash - Our Code
Length Run 1Run 2Run 3Run 4Run 5
1 (burst) 67037373737
1 (word) 65321212121
4 (burst) 67239393939
4 (word) 65522222222
70 (burst) 197069696969
70 (word) 198184848484
128 (burst) 3266100100100100
128 (word) 3301140140140140
1024 (burst) 21466604604604604
1024 (word) 218941036103610361036
2047 (burst) 422821195119511951195
2047 (word) 431452063206320632063
2048 (burst) 422661180118011801180
2048 (word) 431422060206020602060

If the source is declared without any attribute, its address is at 0x3FFB43C0, which is in internal data space, target to internal SRAM2. (Same as when using stdlib)

Cycle Count - Copy from Data RAM Stack - Our Code
Length Run 1Run 2Run 3Run 4Run 5
1 (burst) 3737373737
1 (word) 2121212121
4 (burst) 3838383838
4 (word) 2222222222
70 (burst) 6868686868
70 (word) 8484848484
128 (burst) 100100100100100
128 (word) 140140140140140
1024 (burst) 604604604604604
1024 (word) 10361036103610361036
2047 (burst) 11951195119511951195
2047 (word) 20632063206320632063
2048 (burst) 11801180118011801180
2048 (word) 20602060206020602060

If the source is declared as DRAM_ATTR const static, its address is at 0x3FFB160C, which is in internal data space, target to internal SRAM2. (Same as when using stdlib)

Cycle Count - Copy from Data RAM Static - Our Code
Length Run 1Run 2Run 3Run 4Run 5
1 (burst) 3737373737
1 (word) 2121212121
4 (burst) 3838383838
4 (word) 2222222222
70 (burst) 6868686868
70 (word) 8484848484
128 (burst) 100100100100100
128 (word) 140140140140140
1024 (burst) 604604604604604
1024 (word) 10361036103610361036
2047 (burst) 11951195119511951195
2047 (word) 20632063206320632063
2048 (burst) 11811181118111811181
2048 (word) 20602060206020602060

If the source is declared as IRAM_ATTR const static, its address is at 0x40083608, which is in internal instruction space, target to internal SRAM0. (Different from when using stdlib, because adding our copy function in instruction RAM) Furthermore, it is placed just after our burst copy function.

Cycle Count - Copy from Instruction RAM - Our Code
Length Run 1Run 2Run 3Run 4Run 5
1 (burst) 4545454545
1 (word) 2929292929
4 (burst) 5353535353
4 (word) 3737373737
70 (burst) 195195195195195
70 (word) 211211211211211
128 (burst) 331331331331331
128 (word) 372372372372372
1024 (burst) 24032403240324032403
1024 (word) 28362836283628362836
2047 (burst) 47794779477947794779
2047 (word) 56475647564756475647
2048 (burst) 47714771477147714771
2048 (word) 56525652565256525652

Conclusion

Cycle Count - Conclusion - Our Code
Length 1470128102420472048 Speed (cycle/byte @ 2048B)
Flash (Miss) - Burst 67067219703266214664228242266 20.638
Flash (Miss) - Word 65365519813301218944314543142 21.065
Flash (Hit) - Burst 37396910060411951180 0.576
Flash (Hit) - Word 212284140103620632060 1.006
Data RAM (Stack) - Burst 37386810060411951180 0.576
Data RAM (Stack) - Word 212284140103620632060 1.006
Data RAM (Static) - Burst 37386810060411951181 0.577
Data RAM (Static) - Word 212284140103620632060 1.006
Instruction RAM - Burst 4553195331240347794771 2.330
Instruction RAM - Word 2937211372283656475652 2.760

The result shows:

Our Code vs stdlib

Cycle Count - Our Code vs stdlib
Length 1470128102420472048 Speed (cycle/byte @ 2048B)
Flash (Miss) - stdlib 67367519843274215304240742394 20.700 (baseline)
Flash (Miss) - Burst 67067219703266214664228242266 20.638 (+0.3%)
Flash (Miss) - Word 65365519813301218944314543142 21.065 (-1.8%)
Flash (Hit) - stdlib 40428611367313251313 0.641 (baseline)
Flash (Hit) - Burst 37396910060411951180 0.576 (+10.1%)
Flash (Hit) - Word 212284140103620632060 1.006 (-56.9%)
Data RAM (Stack) - stdlib 39418511267213241312 0.641 (baseline)
Data RAM (Stack) - Burst 37386810060411951180 0.576 (+10.1%)
Data RAM (Stack) - Word 212284140103620632060 1.006 (-57.0%)
Data RAM (Static) - stdlib 39418511267213241313 0.641 (baseline)
Data RAM (Static) - Burst 37386810060411951181 0.577 (+10.1%)
Data RAM (Static) - Word 212284140103620632060 1.006 (-56.9%)
Instruction RAM - stdlib Error48Error3372465Error4897 2.391 (baseline)
Instruction RAM - Burst 4553195331240347794771 2.330 (+2.6%)
Instruction RAM - Word 2937211372283656475652 2.760 (-15.4%)

Compare our code to the stdlib in ROM:

, however:

In terms of memories type:

Memory Congestion

ESP-32 comes with a dual-core CPU and DMAs. In other words, there are multiple masters (CPU cores and DMAs) accessing the memories at the same time.

The RAM is made from 32kB (0x8000) banks, each bank can only serve one master at a time. If multiple masters are accessing the same bank at the same time, the speed decreases.

To simulate a memory congestion, I will launch the second core and that core will continuously write the destination.


DRAM_ATTR char __attribute__((aligned(4))) dest[MAXSIZE];
IRAM_ATTR void spin(void* param) {
	uint32_t tmp;
	asm volatile (
		"1:	\n\t"
		"movi	%[tmp], -1\n\t"
		"loopnez	%[tmp], 2f\n\t"
		"s32i	%[tmp], %[dest], 0\n\t"
		"2:	\n\t"
		"j	1b\n\t"
	:
		[tmp] "=&a" (tmp)
	:
		[dest] "a" (dest)
	:
		"memory"
	);
}

IRAM_ATTR void app_main(void) {
	xTaskCreatePinnedToCore(spin, "Spin", 2048, NULL, 15, NULL, APP_CPU_NUM);
	vTaskDelay(1000 / portTICK_PERIOD_MS);

	/* Benchmarking */
}
	

The second core is executing spin. Inside it, a zero-overhead loop is used to access dest at a frequency of 1 access per cycle.

Furthermore, we will add a secondary destination dest2 that is aligned by 32kB and its size is 32kB. In this way, dest2 is stored in a dedicated bank.


DRAM_ATTR char __attribute__((aligned(32*1024))) dest2[32*1024];
	

We uses our 32-byte burst copy. In that function, for each 32 bytes, we perform 8 reads, 8 writes and 2 index increments. That is in 88% of the time, accessed at 0.5 writes per cycle and 0.5 reads per cycle.

Let's check the benchmarking:

The first core is executing _faststr_memcpy48, at address 0x40083740, its bank's address is 0x40080000. In another word, the first core fetches bank 0x40080000 at 1 fetch per cycle.

The second core is executing spin, at address 0x40082570, its bank's address is 0x40080000. In another word, the second core fetches bank 0x40080000 at 1 fetch per cycle. Frequency is 1 write per cycle.

The second core is writing dest, at address 0x3FFC0000, its bank's address is 0x3FFC0000.

Cycle Count - Memory Congestion - Our Burst Copy 2048 Bytes
Case DestinationSource Run 1Run 2Run 3Run 4Run 5
1 dest0x3FFC0000+0000Flash0x3F400000+30D8 422691180118011801180
2 dest0x3FFC0000+0000D-RAM0x3FFC0000+2CE0 20692069206920692069
3 dest20x3FFB8000+0000D-RAM0x3FFC0000+2CE0 11811181118111811181
4 dest0x3FFC0000+0000I-RAM0x40080000+3790 47714771477147714771

The result is interesting:

  1. The throughput is not affected: 2 cycles per word. When:

    • Bank 0x3FFC0000 in data RAM - 1.5 access/cycle: 0.5 writes from core 1, 1 write from core 2.

    • Flash 0x3F400000 - 0.5 access/cycle: 0.5 reads from core 1.

    • Bank 0x40080000 in instruction RAM - 2 access/cycle: 1 fetch from core 1, 1 fetch from core 2.

  2. The throughput is halved: 4 cycles per word. When:

    • Bank 0x3FFC0000 in data RAM - 2 access/cycle: 0.5 reads from core 1, 0.5 writes from core 1, 1 write from core 2.

    • Bank 0x40080000 in instruction RAM - 2 access/cycle: 1 fetch from core 1, 1 fetch from core 2.

  3. The throughput is not affected: 2 cycles per word. When:

    • Bank 0x3FFC0000 in data RAM - 1.5 access/cycle: 0.5 reads from core 1, 1 write from core 2.

    • Bank 0x3FFB8000 in data RAM - 0.5 access/cycle: 0.5 writes from core 1.

    • Bank 0x40080000 in instruction RAM - 2 access/cycle: 1 fetch from core 1, 1 fetch from core 2.

  4. The throughput is not affected: 8 cycles per word. When:

    • Bank 0x3FFC0000 in data RAM - 1.5 access/cycle: 0.5 writes from core 1, 1 write from core 2.

    • Bank 0x40080000 in instruction RAM - 2.5 access/cycle: 0.5 reads from core 1, 1 fetch from core 1, 1 fetch from core 2.

For data RAM, when the access rate hits 2, the throughput halved. For instruction RAM, probably because the speed is already low, or the CPU caches instruction, the throughput rate doesn't change when access rate hits 2.