Title: Proposals_to_adjust_memory_gas_costs
Category: Magicians

Author: vbuterin
Date: 2022-07-22T05:09:27.889Z

Some ideas for improving how the EVM charges gas for memory, and how much gas child call frames are allowed to access. The key goals are to simplify the rules, make it more clear what the invariants are, and to increase the amount of memory available to legitimate applications.
See post here (uses some latex so can’t post here directly):



HackMD



Proposals to adjust memory gas costs - HackMD
# Proposals to adjust memory gas costs  Currently, we limit the memory consumption of the EVM in two

Author: gcolvin
Date: 2022-07-22T21:14:54.555Z

I generally support this - what we have is a mess and this is much, much better.
My basic concern is that if I understand you, “it doesn’t actually cost any resources to use memory” isn’t entirely true. As I said on the Discord:

… RAM is addressed in pages, and CPU cache in lines, and writing to an address touches only that line and page, not the intervening ones.  How expensive that is depends on lots of things that we probably should consider more than we have – see What Every Programmer Should Know About Memory

So indeed the size of the address range doesn’t matter, or even the total amount of memory used.  To a first approximation it’s the size of the working set vs. the amount of room left in the CPU caches for EVM memory that matters. If the working set is too large CPU caches will spill to RAM and memory access gets much slower.
Since each CALL requires spinning up a new VM instance and loading the code of the called contract it seems that caches may be spilling then anyway, so limiting memory use per VM as well as in total makes sense to me.  But I don’t have numbers for how large the working set of EVM instances tends to be or how much is used by the rest of the client.  Given the numbers, I suspect a good formula might go quadratic per VM in the call stack to keep each VM from using too much cache, but accumulate across VMs.

Author: vbuterin
Date: 2022-07-23T07:15:48.201Z

gcolvin:

Given the numbers, I suspect a good formula might go quadratic per VM in the call stack to keep each VM from using too much cache, but accumulate across VMs.


Interesting… so it might make sense to add an extra layer of quadratic pricing per VM to account for large memory leading to memory being stored in less efficient caches? Though if that’s true, then the true optimum might be to make the cost of MLOAD and MSTORE (and copying) depend on the log of the memory size?
I do think that things like that could be added on top of the framework, though there’s the tradeoff of whether or not it’s worth it to fine-tune and accept the cost of increased complexity that comes as a result.

Author: lekssays
Date: 2022-07-23T17:11:54.379Z

vbuterin:

Though if that’s true, then the true optimum might be to make the cost of MLOAD and MSTORE (and copying) depend on the log of the memory size?
I do think that things like that could be added on top of the framework, though there’s the tradeoff of whether or not it’s worth it to fine-tune and accept the cost of increased complexity that comes as a result.


I support the change because memory cost management (as mentioned in the post it is rather inconsistent).
I have few points to mention from the post and the discussion. I agree with @gcolvin on the limit of memory use per VM and making the cost quadratic because it serves the purpose of increasing the available amount of memory for legitimate applications and “punishing” the malicious applications that would require larger memory. I might be wrong, but up to my understanding, since the idea of quadratic cost is also used in the current version, there might be another concern about the number of VMs to be initiated (call-frame in the current version). Would we go back to another 63/64 rule? So, we might have the same problems again.
On the point of fine-tuning, I think that all the proposals are going towards that direction because fine-tuning will allow us to reach an essential design goal: clear formula for memory pricing. So,  defining new costs for MLOAD and MSTORE is a step towards that direction. I agree with linking the cost to the memory size, but I think that the function to use depends on many factors like @gcolvin mentioned. I might be wrong again, but I am not sure if MLOAD and MSTORE should be handled the same way in the first place.

Author: gcolvin
Date: 2022-07-23T22:25:15.436Z

The big hit is that RAM is a full order of magnitude slower that cache. An interesting experiment would be a program that loops, reading and writing randomly within a buffer.  As the size of the buffer increases I’d expect the throughput to plummet when the I/O starts overflowing cache.  I think we want a cost function that discourages contracts from falling off the cliff.
Thus the quadratic cost function.  I’ll need to do some measurement to see if that’s the right function.

Author: gcolvin
Date: 2022-07-24T03:57:22.264Z

These are numbers for a C program writing bytes randomly onto buffers of increasing size. Cleary, the writes/sec are not constant.
Buffer     Writes/sec
131072      230929744
262144      230929744
524288      182587040
1048576     165130960
2097152     145794608
4194304     116369328
8388608      59452260
16777216     45211876
33554432     41870968

wps1650×1275 122 KB

Author: Recmo
Date: 2022-07-24T05:06:05.902Z

writing bytes randomly

Most programs don’t write randomly though, in part because this has bad performance like shown in the graph. Most common access patterns are 1) stack-like, where access clusters around recent additions, before being removed again. (I suspect EVM mem usage is primarily like this), and 2) linear scans, where array data is repeatedly accessed in order. CPU Caches are really good at handling those loads. LRU eviction policies handles the former, speculative prefetching the second.
The exceptions to these patterns are datastructures like trees and hash tables, and algorithms like sorting, matrix multiplication and FFTs. For these there is a lot of research to make them more cache-friendly. This is the main motivation behind B-trees and cache-aware and cache-oblivious algorithms.
I’m bringing this up because the literature of cache-aware/oblivious algorithms has already addresses the problem of modeling the real cost of memory access with the cache-aware memory model and the cache-oblivious model respectively.
The difference between the two is roughly whether the algorithm knows how the caches work and can adjust its behaviour, or is oblivious to it. For example, if you are using files or network storage, the algorithm is aware. For Ram and CPU caches this does not apply. In fact, the exact details of caches vary wildly between processors (much like how EVM runs on a variety of hardware) and the only strategy is to create algorithms that work great on any cache hierarchy. So for present discussion the relevant literature is the cache-oblivious one.
The cache-oblivious cost model is approximately as follows:

The cache contains words of size B
It stores at least B such words
When a new entry is inserted (after a cache miss), the oldest* one is removed.

The complexity measure is the number of cache-misses/insertions.
*: The actual model uses a theoretical optimal cache that can perfectly predict future access, but it turns out just evicting the oldest one is a real good approximation.

Author: Recmo
Date: 2022-07-24T05:56:57.398Z

I also want mention that modern (as in last 5 years) CPU caches are several megabytes in size on L3. It is very unlikely that EVM transactions will exceed this.
There is also a sizable cost associated to accurately accounting for memory usage. The EVM implementation needs to check the memory bound for each MLOAD/MSTORE and conditionally check gas, maybe allocate more memory, etc. For values that are in cache (i.e. nearly all) this is likely more expensive than the memory operation itself.
We could also allocate each call-frame a fixed amount like 1MB and do away with all that. 1MB is a generous amount: even if you could allocates more, you would not have any gas left to do anything useful with it. With 1MB and a ~1024 call-depth limit, the EVM needs at most 1GB. This sounds like a lot, but memory doesn’t cost anything unless it is accessed and there is not enough gas to access the most of it.
I’d also love to see an chain-history analysis of the max memory size distribution for all historical call-frames.

Author: gcolvin
Date: 2022-07-24T14:17:59.529Z

Recmo:

Most programs don’t write randomly though, in part because this has bad performance like shown in the graph


I was trying to simulate the worst case, since that would be the DoS exploit.



 Recmo:

There is also a sizable cost associated to accurately accounting for memory usage.


The worst case is that any read or write could cause a miss, which could be reflected in the fixed cost for MSTORE and MLOAD.

Author: lekssays
Date: 2022-07-24T21:39:31.623Z

Recmo:

We could also allocate each call-frame a fixed amount like 1MB and do away with all that. 1MB is a generous amount: even if you could allocates more, you would not have any gas left to do anything useful with it. With 1MB and a ~1024 call-depth limit, the EVM needs at most 1GB. This sounds like a lot, but memory doesn’t cost anything unless it is accessed and there is not enough gas to access the most of it.


I think that the limit should take into consideration also the number of call-frames that can be initiated. In order to have a good analysis to define limits and cost functions, we need historical data of memory usage of different contracts including possible DoS ones. Nowadays, L3 cache are around 8MB. It should be enough for the majority of the cases, but we are not sure if there was some case in the past that required more.
In general, we all agree on fine-tuning the cost of MLOAD and MSTORE.

Author: gcolvin
Date: 2022-07-25T00:07:21.211Z

What we see in this worst-case experiment doesn’t fit a quadratic increase in memory cost.  Rather, performance does an approximately exponential decay from 230M to 42M writes-per-second at 16Mbytes.  (Different system will of course give different results.)
At that 6:1 ratio, a cost formula of 3 + floor(log(MSIZE)) might fit pretty well, starting at 3 gas and rising to 16 gas at 32 megabytes. However, that spread also seems small enough to just choose a flat fee somewhere in that range – I’d guess about 6.

Author: vbuterin
Date: 2022-07-25T06:40:32.564Z

gcolvin:

However, that spread also seems small enough to just choose a flat fee somewhere in that range – I’d guess about 6.


This is my intuition too. Especially once you take into account EVM overhead, it doesn’t feel like the cost of an MLOAD with large memory vs small memory is that large. So we should try to make sure that a plain 3 gas per memory op isn’t fine enough as it is.

Author: gcolvin
Date: 2022-07-27T17:26:45.748Z

I’ll note that the push opcodes are at 3, and they are nearly a best case, with sequential access to adjacent words on the stack.  So unless interpreter overhead dominates I’d expect random access to memory (the DoS surface) to require a bit more.  I need to get set up to measure it.

Author: sergio_lerner
Date: 2022-07-28T21:24:31.551Z

There is a problem with the proposal 1 is that increasing the stack depth past 1024 may bring new DoS vulnerabilities to some Ethereum client implementations. Proposal 1 sets a new maximum depth of 29296.
There are two known methods to manage commit/rollback of world state changes: 1) track only new key/values (also called state cache) or 2) track changed values (old and new) (also called state journal or state log). In a VM with N nested calls, there are N active caches but only one active journal.
In a cache, every time a cache is committed (a CALL is successful), the contents of the cache at level N are copied into the cache at level (N-1). When a SLOAD operation occurs, the cache at level N is queried and if the key is not there, then the cache at level N-1 is queried and so on recursively until level 0 (the trie) is reached. Let’s assume that a certain key is warm at level 1 (meaning it has already been fetched from the trie). At stack level 29000, a SLOAD gas cost would need to be k*29000 for k approximately equal to 10 (290k gas). But the cost of a warm SLOAD is currently 100.  This high difference (100 vs 292k gas) introduces a new DoS attack vector on those clients.
Geth doesn’t use a state cache, but a journal. It won’t be vulnerable.
However BESU uses the cache model, and I think it will.

Author: sergio_lerner
Date: 2022-07-28T21:29:36.763Z

I think there is minor error in the proposal 2:


A call frame gets a max_memory parameter; memory expansion beyond that value is forbidden. The outermost call frame gets max_memory = block.gaslimit * GAS_PER_MEMORY_BYTE


This should be:
max_memory = block.gaslimit // GAS_PER_MEMORY_BYTE

Author: vbuterin
Date: 2022-07-30T07:29:18.158Z

sergio_lerner:

Geth doesn’t use a state cache, but a journal . It won’t be vulnerable.
However BESU uses the cache model, and I think it will.


OK I did not realize this. For some reason I had thought that the cache model was a temporary measure back in 2016 because it’s easier to implement, but everyone had moved to a journal since then.
What’s the justification for any VM implementation using the cache model?
If we want to continue being friendly to the cache model, the perhaps we would need to stick to the 63/64 model (and perhaps even make the max fraction lower).

Author: gcolvin
Date: 2022-07-30T13:55:10.337Z

I’m just not set up to time it here, but if anyone else is, I’d try something like this:
contract MemTimes
{
    uint256[2**20] mem;             // million words, thirty-two megabytes
    uint256        num = 2**89 - 1; // pseudo-random number
    function rand() private returns (uint256) {
        num = (((2^107-1)*num + (2**127 - 1))^2) >> 2**12;
        return num;
    }
    function shred(uint256 n) public {
        for (uint256 i = 0; i < n; i++) {
            uint256 u = rand();
            mem[i] ^= u;
        }
    }
    function thrash(uint256 n) public {
        for (uint256 i = 0; i < n; i++) {
            uint256 u = rand();
            mem[u] ^= u;
       }
    }
}

Author: gcolvin
Date: 2023-03-17T01:33:40.159Z

Reading back over this I’m inclined to just say that the cache model is not appropriate, but I don’t know if there are reasons that Besu or other clients can’t use a state log.

Author: gcolvin
Date: 2023-05-31T15:54:25.272Z

@vbuterin  Do you still want to move this proposal forward?  It would be a huge simplification of the existing rules.  With EOF coming in Prague this would be a good time to do it.

Author: gcolvin
Date: 2023-06-17T00:31:15.461Z

… the literature of cache-aware/oblivious algorithms …

I notice I forgot to thank you for this really nice summary, @Recmo

Author: gcolvin
Date: 2023-06-24T00:47:37.206Z

So I did more careful testing, adjusting for the time spent generating random numbers to write and random positions to write to.  The difference between random and sequential access became much less, and the effect of buffer size almost disappeared.  My take is that 30MB of memory isn’t enough to cause a modern Core I7 with a 12MB L3 cache to thrash.

Author: vbuterin
Date: 2023-06-27T11:47:34.442Z

I would love to see it happen!

Author: charles-cooper
Date: 2023-06-30T07:41:16.687Z

There is also a sizable cost associated to accurately accounting for memory usage. The EVM implementation needs to check the memory bound for each MLOAD/MSTORE and conditionally check gas, maybe allocate more memory, etc. For values that are in cache (i.e. nearly all) this is likely more expensive than the memory operation itself.

I suspect that even if the value is not in the cache, the accounting around memory is more expensive than the read or write itself. This is somewhat anecdotal but while working on a faster memory implementation for py-evm (titanoboa/boa/vm/fast_mem.py at b8a0f2ad549b7a905aec28e6772198c9ba07d9fb · vyperlang/titanoboa · GitHub) I found that most (something like 70-90%) of time involving the memory access itself was in the expansion check and converting between bytes and ints from the stack. I was able to remove this overhead in the common case where reads or writes are aligned by modeling memory as an array of words alongside the bytearrays and writing between them when bytes were needed instead of ints. Actually this suggests to me that we should charge more for unaligned accesses and less for unaligned accesses, Python isn’t super efficient though so I don’t know how that works on other clients.

With 1MB and a ~1024 call-depth limit, the EVM needs at most 1GB. This sounds like a lot, but memory doesn’t cost anything unless it is accessed and there is not enough gas to access the most of it.

Hmm I am not so sure about this, you have to allocate the memory which is expensive. You can either allocate it up-front or on-demand. I didn’t really see this cost addressed in the proposal but maybe I missed it.

Author: gcolvin
Date: 2023-06-30T19:14:33.511Z

you have to allocate the memory which is expensive.

Is it?  On my Mac I can malloc() terabytes of memory in very little time.  And I can write to it randomly in not much time until I write enough to start using a lot of physical pages.  But as you say, the interpreter overhead and accounting dominate the cost of the writes themselves long before memory access starts to thrash noticeably.

Author: charles-cooper
Date: 2023-06-30T19:59:23.641Z

I don’t think the issue is the amount of memory allocated but overhead per-call to malloc() (which memory resizes presumably trigger). At least, that is my understanding from talking to some client devs, that one of the more expensive aspects of issuing a CALL is allocating the memory frame.
For reference, here is a short benchmarking c++ script where I benchmark each 30MB allocation (including zeroing memory) costs 45ms(!), 32ms of which is spent in syscall land sorry, I made a mistake with the allocation size in my original post, it costs 1.1ms, 25us of which is in syscall land:
~ $ g++ --version
g++ (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

~ $ cat test_malloc.cpp 
#include <vector>

int main() {
    for (int i = 0; i < 1000; i++) {
        // EDITED: was originally std::vector<int>
        std::vector<char> v(30*1000*1000, 0);
    }
}
~ $ g++ -O2 test_malloc.cpp 
~ $ time ./a.out 

real	0m1.155s
user	0m1.130s
sys	0m0.025s

(I originally used malloc directly but it seemed gcc kept optimizing those out).
I also tested some variants and it seems for small enough sizes the allocator might be able to use a pool, but I didn’t investigate further.

Author: gcolvin
Date: 2023-07-03T23:50:54.212Z

Interesting, thanks  There are a lot of variables.  My tests used malloc(), and enough volatile declarations to keep the compiler from not calling it, or from writing to or reading from the allocated memory.  Clients and their development languages will use different strategies for lazy or greedy allocation of memory, which are going to affect the cost of a CALL.  Overall I think we undercharge.

Author: gcolvin
Date: 2023-07-04T15:25:38.959Z

Dug up some prior benchmarching that indicates that interpreter overhead runs about 1 gas.  Even on a very slow CPU it takes 10ns to burn a unit of gas.  On recent CPUs it takes less than a nanosecond to get a cached value to a register.  One gas can pay for a lot of overhead.
So how badly could an attacker abuse the increasing cost of widely spread random access?  Worst case, for this data, writing words randomly to fill 32Mb costs about 4 to 5 times more than filling 1Mb. (Sequential access is faster than random access, but not enough to change the results of this analysis.)   So if the base cost for memory operations corresponds to randomly filling 1 mem of memory an attacker could could use a larger buffer to force an app to use about 4 times as much gas as paid for.  However, there is also the memory expansion cost of 32 gas per word, which overwhelms the cost of access.  Differences in these parameters are not going to matter much given the extension cost.  So my former analysis holds – we can pick a cost for memory access that is independent of pattern of access.  The 3 gas we have now likely suffices – 1 gas for the interpreter, 2 gas for the block copy of the data…
It’s also worth noting that the memory extension cost is not about the cost of allocating memory – on modern systems that’s almost free until memory is touched.  It’s about limiting the amount of address space a client can possibly use.  That matters because if the working set of a system – the total amount of address space used by a client – is larger that the available CPU cache, and worse, available RAM, (after the OS and other programs get their share,) then cache lines will spill to RAM and the system will start to thrash.  With modern CPUs caching megabytes of data, held in gigabytes of RAM, 30Mb seems quite reasonable.   Note that 30Mb of gas costs $1200 right now, and historically that is very low.  So exploits involving extending memory would be expensive.

Author: charles-cooper
Date: 2023-07-04T17:26:02.846Z

New memory needs to be zeroed before use though, which is not free. The benchmark I posted above suggests 1ns per 32 bytes. If 1 gas is supposed to reflect 10ns of CPU time then that means 1ms of overheard would be 100k gas. If you do something smarter and only zero memory before first use, then you need to start tracking which parts of memory have been touched etc.
So maybe the best thing then (subject to further benchmarking ofc) is to simply charge a linear cost of 1 gas per ~10 words (that is, pricing it at 1 gas per 10ns) for memory expansion. And maybe the cost for accessing memory should reflect the worst case cost of hitting RAM, which IIRC is about 70ns, so 7 gas (could be off here though since the last time I checked latency numbers was like 7 some years ago).

Author: gcolvin
Date: 2023-07-04T17:54:54.878Z

(The 10ns was a truly ancient machine.)  My measurements didn’t see any difference between a write and a blind read.  And pages obtained via mmap() come zeroed “for free”.  But I think the fees here weren’t chosen by actual cost, but to behave close to the current gas cost function.  That’s why they overwhelm the actual memory access.  Which I think is appropriate – memory access to (predictably) cached data is almost free, but if the system can be made to thrash because of a bloated working set it is a horrible attack vector.  I think we may be overcharging for that purpose, but we would need to measure the memory use of systems running clients on the range of machines we want to support.  How light can a light client go?

Author: charles-cooper
Date: 2023-07-04T19:07:13.841Z

And pages obtained via mmap() come zeroed “for free”.

Ah, I see. Well I guess there is a small overhead once you need to fault the page but I’m guessing it’s negligible for our purposes (worth checking though ofc). I’m wondering though how many language runtimes actually guarantee returning mmap’ed pages when you allocate memory?
Btw so we have the numbers on hand, I just ran the numbers for mmap and it looks like about 230ns per mmap / munmap. Which is small, but not entirely trivial:
~ $ cat test_mmap.cpp 
#include <sys/mman.h>
#include <cstddef>

int main() {
    for (int i = 0; i < 1000 * 1000; i++) {
        void *p = mmap(NULL, 30 * 1000 * 1000, PROT_READ | PROT_WRITE, MAP_ANONYMOUS, -1, 0);
        munmap(p, 30* 1000 * 1000);
    }
}
~ $ g++ -Os test_mmap.cpp 
~ $ time ./a.out 

real	0m0.241s
user	0m0.053s
sys	0m0.188s

Author: gcolvin
Date: 2023-07-04T19:52:50.074Z

I’m coming to believe that I would just mmap() the whole 30Mb.

Author: charles-cooper
Date: 2025-03-24T11:46:04.851Z

Was just tinkering, and redid the script to actually test how expensive writing to the allocated memory would be, and looks quite expensive:
$ cat test_mmap.cpp 
#include <sys/mman.h>
#include <cstddef>
#include <cstdio>

int main() {
    for (int i = 0; i < 1000 * 1000; i++) {
        char *p = (char *)mmap(
            NULL,
            30 * 1000 * 1000,
            PROT_READ | PROT_WRITE,
            MAP_PRIVATE | MAP_ANONYMOUS,
            -1,
            0
        );
        *(p + i * 10) = 10;
        munmap(p, 30* 1000 * 1000);
    }
}
$ gcc -O3 test_mmap.cpp 
$ time ./a.out 

real	0m6.638s
user	0m0.363s
sys	0m6.188s

6us per loop

Author: charles-cooper
Date: 2025-03-24T13:10:12.681Z

Just so we have the numbers, I additionally benchmarked the cost to malloc a 4KB page. It’s about 1.3us:
#include <vector>
#include <cstdlib>
#include <cstring>

#define COUNT (1000*1000)

int main() {
    std::vector<void*> v;
    v.reserve(COUNT);
    for (int i = 0; i < COUNT; i++) {
        void *p = calloc(1, 4096);  // ensure allocated memory is zeroed
        v.push_back(p);
    }
}
$ g++ -O3 test_malloc.cpp 
$ time ./a.out 

real	0m1.387s
user	0m0.227s
sys	0m1.147s


I wanted to be careful and account for the case where the EVM implementation is a bit smarter about allocating memory and uses a free list, in order to minimize calls to malloc. In that case, we only need to benchmark memset. It costs ~250ns per page on my machine (as is hinted by the user number in the previous benchmark):
~ $ cat test_memset.cpp 
#include <vector>
#include <cstdlib>
#include <cstring>

// 100k pages, or 400MB
#define COUNT (100 * 1000)

int main() {
    char *p = (char *)malloc(4096 * COUNT);

    // run memset 10mm times
    for (int i = 0; i < 10 * 1000 * 1000; i++) {
        memset(p + (4096 * (i % COUNT)), 0, 4096);
    }
}
~ $ g++ -O3 test_memset.cpp 
~ $ time ./a.out 

real	0m2.694s
user	0m2.556s
sys	0m0.136s

I’m running all these numbers on the same machine as the previous benchmarks in this thread – the CPU is an i7-10710U. The Linux might be updated compared to before, it’s 6.8.0-55-generic.

Author: chfast
Date: 2025-03-24T15:13:52.018Z

This benchmark is incorrect. Compiler Explorer

Author: charles-cooper
Date: 2025-03-24T18:26:24.460Z

What’s incorrect about it?
Screenshot from 2025-03-24 19-19-37606×589 42.1 KB
I forgot to mention by the way that I’m using gcc 13.3. But I just checked the output, and it doesn’t really make much difference (Compiler Explorer).

Author: chfast
Date: 2025-04-27T08:22:49.005Z

The compilers are significantly modifying your program. Either they completely remove both malloc and memset (clang), or they combine all memsets into single one and inline it as a single rep stosq instruction. This is because the loop count is constant. In other words, the is not even a single call to memset in this benchmark.

Author: charles-cooper
Date: 2025-04-27T11:32:32.950Z

chfast:

Either they completely remove both malloc and memset (clang)


I said I used gcc for the benchmark. Not sure how clang is relevant here



 chfast:

or they combine all memsets into single one and inline it as a single rep stosq instruction.


That’s not what the assembly says. The assembly says that rep stosq (which zeroes memory) is executed in a loop. Even if you don’t believe my reading of the assembly, you can believe the CPU. The time to execute this program is linear in the loop bound, not in the size of memory allocated. For instance, if you increase the loop bound to 20mm, it takes twice as long to run.



 chfast:

In other words, the is not even a single call to memset in this benchmark.


Not sure why that’s relevant - it’s common for memset to be inlined and compiled using rep stosq (see for instance: What does the "rep stos" x86 assembly instruction sequence do? - Stack Overflow).
The point was to measure the CPU cost of zeroing memory, not literally measure the overhead of calling the memset builtin