Python 3.11 and Function Call Frequency

My cProfile profiling run time has gone from 1.5 - 2.5 times normal execution speed in python3.9 to 10 times or more in python 3.11 and 3.14.

Google says it’s because of PEP 659 and cites " Function Call Frequency" as one of the possible reasons.
“If your code contains short, frequently called functions or operates in “hot loops”, the cumulative stopwatch latency compounds, drastically amplifying execution time.”

I make a lot of method calls.

Profiling pypy3 shows

     123918383 function calls (123902791 primitive calls) in 8.705 seconds

several methods are called over a million times a second.

 13862470    0.144    0.000    0.144    0.000 stream.py:182(_pusi_flag)
 18290253    0.191    0.000    0.191    0.000 stream.py:210(_parse_pid)
 18303310    0.211    0.000    0.212    0.000 stream.py:270(packetize)
 18290246    0.846    0.000    5.227    0.000 stream.py:286(iter_pkts)
 18290245    0.418    0.000    2.875    0.000 stream.py:312(pkt2cue)
 18290246    0.845    0.000    2.173    0.000 stream.py:541(_parse)

Without profiling, my code on python 3.9 takes about 9 seconds, on 3.11 it takes 10 seconds, and python 3.14 takes 14 seconds.

I’m trying to understand why because everything I read says 3.14 is faster.

Is it feasible that my method call frequency is slowing me down even when I’m not profiling?

So last night I consolidate several methods and bought my method calls down 123 million to 69 million, but that didn’t help profile any faster.

Let me provide a bit more information.

When I run my code on python3.14 it takes 14 seconds to run.
When I profile the same code it takes 214 seconds to profile

 
         68859616 function calls (68859312 primitive calls) in 214.858 seconds
  • For example, I have a static method that takes one arg, pkt, which is always 188 bytes and It just returns a bitwise and against the second byte.
    @staticmethod
    def _pusi_flag(pkt):
        return pkt[1] & 0x40

profiled on python3.14

 13862470   19.124    0.000   19.124    0.000 stream.py:183(_pusi_flag)

I don’t understand why that static method takes so much longer when I profile it. I am happy to change the method, I just don’t know how I should change it to make it profiler friendly

It’s really only the methods that I call several million times that seem to do so poorly when I profile them.

  • python 3.14
13862470   19.124    0.000   19.124    0.000 stream.py:183(_pusi_flag)
  2481907    3.474    0.000    3.474    0.000 stream.py:187(_afc_flag)
  1455459    2.037    0.000    2.037    0.000 stream.py:199(_pts_flag)
      294    0.000    0.000    0.000    0.000 stream.py:204(_parse_length)
 18290253   26.363    0.000   26.363    0.000 stream.py:211(_parse_pid)
        2    0.000    0.000    0.000    0.000 stream.py:219(_parse_program)
      288    0.001    0.000    0.002    0.000 stream.py:226(_split_by_idx)
        1    0.000    0.000    0.000    0.000 stream.py:242(_find_start)
        1   33.093   33.093  214.858  214.858 stream.py:328(decode)
  1967260    2.987    0.000    2.987    0.000 stream.py:439(pid2prgm)
  1458021    4.081    0.000    6.242    0.000 stream.py:472(_unpad)
      286    0.003    0.000    0.009    0.000 stream.py:475(_mk_packet_data)
  1455459    3.055    0.000    3.055    0.000 stream.py:482(mk_pts)
  1455459   13.323    0.000   38.119    0.000 stream.py:494(_parse_pts)
  2481907   10.349    0.000   20.066    0.000 stream.py:520(_parse_payload)
   511800    2.382    0.000    3.895    0.000 stream.py:531(_pmt_pid)
  1023600    4.650    0.000   13.965    0.000 stream.py:539(_parse_tables)
 18290246   82.913    0.000  180.724    0.000 stream.py:554(parse)
      288    0.001    0.000    0.003    0.000 stream.py:587(_chk_partial)
   511801    0.826    0.000    0.826    0.000 stream.py:592(_same_as_last)

what am I doing wrong that is making my python3.14 profiling so crazy?
Is it simply the number of method calls?

Python needs to create a new frame object for every call made, and the cost can add up, especially when Python does not use tail call optimization and friends.

Does it? I interpret these differently:

Create frame objects lazily when needed

Cheaper, lazy Python frames

Even without the “frame object” in the strict sense, the essential metadata is still there. The changes, though great, cannot fully eliminate this, and we’re talking about millions of calls here with profiling.

Isn’t the problem the OP is pointing out different? They’re asking why it’s so much slower with profiling than without. Without profiling, the OP reports much better performance.

I think @Stefan2 may be on to something. I don’t remember exactly now but I suspect profiling might be interfering with specialization, causing us to use the slower call paths than the fast ones we introduced in 3.11.

So I figured I would simplify things, and it appears that the number of calls is the factor when profiling.

I made a script that calls a function that just returns and called it twenty million times.

import cProfile
import sys


def aflag(num):
    return

def flagger():
    for num in range(int(sys.argv[1])):
        aflag(num)    

if __name__ == '__main__':
    flagger()
  • Without Profiling
a@fu:~$ time python3.14 ~/aflag.py 20000000

real	0m0.806s
user	0m0.794s
sys	    0m0.013s

  • With cProfile ( using cProfile.run("flagger()" )
a@fu:~$ time python3.14 ~/aflag.py 20000000
         20000004 function calls in 56.230 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000   56.230   56.230 <string>:1(<module>)
        1   29.140   29.140   56.230   56.230 aflag.py:11(flagger)
 20000000   27.090    0.000   27.090    0.000 aflag.py:5(aflag)
        1    0.000    0.000   56.230   56.230 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}



real	0m56.275s
user	0m10.432s
sys	    0m45.842s

pypy3 does not exhibit the same behavior when profiling, however, the negative timings don’t help much…

a@fu:~$ time pypy3 ~/aflag.py 20000000

         20000004 function calls in -0.200 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1   -0.000   -0.000   -0.200   -0.200 <string>:1(<module>)
        1   -0.101   -0.101   -0.200   -0.200 aflag.py:11(flagger)
 20000000   -0.099   -0.000   -0.099   -0.000 aflag.py:5(aflag)
        1   -0.000   -0.000   -0.200   -0.200 {built-in function exec}
        1   -0.000   -0.000   -0.000   -0.000 {method 'disable' of '_lsprof.Profiler' objects}



real	0m0.596s
user	0m0.404s
sys	    0m0.047s

if profile this function and call it 20 million times it takes ~56 seconds

def aflag(num):
    return 

if profile this function and call it 20 million times it takes ~59 seconds

def aflag(num):
    num |= num >> 4
    num =num << 7
    return num & 0x40

It does seem that the sheer number of function calls is the biggest contributing factor.

I went back to my original script, looking at the _pusi_flag() static method

    @staticmethod
    def _pusi_flag(pkt):
        return pkt[1] & 0x40


Instead of calling it like:

           if self._pusi_flag(pkt):
                self._parse_pts(pkt, pid) 

which profiled like

   68859616 function calls (68859312 primitive calls) in 214.858 seconds

I just inlined the check instead

           if pkt[1] & 0x40:
                self._parse_pts(pkt, pid) 

and got a big speed up with the profiling


48113855 function calls (48113555 primitive calls) in 154.236 seconds

it also shaved a little over a second of my non-profile runtime. which makes me happy :slight_smile:

This makes sense. The profiler present in 3.11 traces every function call. A sampling profiler is available in 3.15 (currently in beta):

Of you can upgrade (3.11 is getting pretty out-of-date), you can take advantage of it.

Tachyon sounds great to me, the name very appropriate too, I like it.
I tried pyinstrument a little, but I’d prefer to use a profiler from python on python code, I think.
3.11 is where I first notice the behavior when profiling, I’m testing with 3.11 and 3.14

What I was doing is reading a video and slicing off 188 byte packets, and calling another method for each packet which in turned called a few more methods.
Now, I when I slice off a packet Instead of calling another method, I processes it right there inside the loop. That eliminated about 100 million method calls. It’s not as pretty but it did drop the run time from 14 seconds to 9 seconds and the profiling time is down from 209 seconds to 48 seconds.

       11533363 function calls (11533063 primitive calls) in 48.979 seconds

Moral of the story, function calls get expensive

Thanks for the feedback y’all, I appreciate it,

How are you slicing off 188 byte packets?

I’m glad you asked, I have spent a lot of time trying to figure the best way to do that for both python3 and pypy3. At one point I was checking sys.version so I could optimize for one or the other.

you’ll see calls to reader, reader returns a file handle, I use it to open files, and http(s), and Udp multicast and unicast so I can deal with everything the same way. For sockets it returns makefile()

I have split packets mostly with iter/partial this
was during my comprehension phase

from functools import partial
from threefive import reader 

PKTSIZE =188
NUMPKTS=1400
CHUNKSIZE= PKTSIZE * NUMPKTS

with reader(tsdata) as rdr:
    for chunk in (partial(rdr.read, CHUNKSIZE), b"")
        cues = [parse(chunk[i : i + self.PACKETSIZE]) for i in range(0, len(chunk), PACKETSIZE)]    
        _=[func(cue) for cue in cues if cue]
  • then I went to a double walrus technique for a while
   import io
   from threefive import reader
   
       with reader(tsdata) as rdr:
           while chunk := io.BufferedReader(rdr, buffer_size=CHUNKSIZE):
               while pkt := chunk.read(PKTSIZE):
                   cue = self._parse(pkt):
                   if cue:
                       func(cue)

I would love to hear any ideas, please..

The one with all the extra function calls looks like this


   def packetize(self, chunk):
        """
        packetize - turn chunk into 188 byte packets
        """
        for i in range(0, len(chunk), self.PACKET_SIZE):
            yield chunk[i : i + self.PACKET_SIZE]

    def chunked(self, num_pkts):
        """
        chunked - read chunks from stream
        """
        chunksize = self.PACKET_SIZE * num_pkts
        if self._find_start():
            while chunk := self._tsdata.read(chunksize):
                yield chunk

    def iter_pkts(self, num_pkts=1400):
        """
        iter_pkts - iterate packets from stream
        """
        for chunk in self.chunked(num_pkts):
            yield from self.packetize(chunk)

      def decode(self, func=show_cue):
        num_pkts = 2100
        for pkt in self.iter_pkts(2100):
            cue = self._parse(pkt)
            if cue:
                func(cue)

this is my current approach to reading 188 byte packets

 def decode(self, func=show_cue):
    num_pkts = 2100
    chunksize = num_pkts * self.PACKET_SIZE
    while chunk := self._tsdata.read(chunksize):
        for i in range(0, len(chunk), self.PACKET_SIZE):
            pkt= chunk[i : i + self.PACKET_SIZE]
            pid = (pkt[1] & 0x1F) << 8 | pkt[2]
            ....

Ok, those all look alright. I wouldn’t describe any of them with “slicing off”, though. That made me think maybe you’re doing something like this:

while chunk:
    process_packet(chunk[:188])
    chunk = chunk[188:]

That could be slow (quadratic time) and then this could be better (linear time):

chunk = bytearray(chunk)
while chunk:
    process_packet(chunk[:188])
    del chunk[:188]

I tried something similar and it was really slow,


while chunk:= tsdata.read(chunksize):
   while pkt := chunk[:188]
        chunk:=chunk[188:]
       

I haven’t been using del or bytearray though.
I haven’t used a bytearray before, I just wasn’t sure when I should.

I figured out where the issue seems to start, it’s when I split off the packets that 3.11 runs faster than 3.14

If I do

def test(arg,num_pkts):
    pkt_size=188
    chunksize = int(num_pkts) * pkt_size
    with open(arg,'rb') as stuff:
        while chunk := stuff.read(chunksize):
            continue

python3.14 runs as fast or faster than 3.11 with
num_pkts = 1,7,70,700,7000, or 70000
(arg is an mpegts video file)

When I change the function to

def test(arg,num_pkts):
    pkt_size=188
    chunksize = int(num_pkts) * pkt_size
    with open(arg,'rb') as stuff:
        while chunk := stuff.read(chunksize):
            for i in range(0, len(chunk), pkt_size):
                pkt= chunk[i : i + pkt_size]
num_pkts python3.11 python3.14.6
1 6.998 6.023
7 5.620 3.963
70 2.953 3.332
700 2.133 3.319
7000 1.941 3.122
70000 2.136 3.293

The reason I keep using multiples of seven is that mpegts often is streamed in 1316 byte datagrams, or 7 packets so it’s best to parse in groups of 7 packets.