Skip to content

Commit

Permalink
tutorial: Improve the 3 tutorials further
Browse files Browse the repository at this point in the history
Include some additional code examples, fix typos, and add more commentary.

Signed-off-by: Dee Lucic <[email protected]>
  • Loading branch information
drz416 authored and godlygeek committed May 21, 2024
1 parent e8fe43c commit 99919ef
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 119 deletions.
6 changes: 3 additions & 3 deletions docs/flamegraph.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ follows:
allocated memory.

- For quickly identifying the functions that allocated more memory
directly, look for large plateaus along the bottom edge, as these show
directly, look for wide boxes along the bottom edge, as these show
a single stack trace was responsible for a large chunk of the total
memory of the snapshot that the graph represents.

Expand Down Expand Up @@ -106,7 +106,7 @@ follows:
graph by default.

And of course, if you switch from the "icicle" view to the "flame" view,
the root jumps to the bottom of the page, and call stacks grow upwards
the root drops to the bottom of the page, and call stacks grow upwards
from it instead of downwards.

Simple example
Expand Down Expand Up @@ -424,7 +424,7 @@ for understanding its memory usage patterns.
about allocations over time. They also can't be used for finding
:doc:`temporary allocations </temporary_allocations>`.

You can see an example of a temporal flamegraph
You can see an example of a temporal flame graph
`here <_static/flamegraphs/memray-flamegraph-fib.html>`_.

Conclusion
Expand Down
2 changes: 1 addition & 1 deletion docs/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ You can also invoke Memray without version-qualifying it:
The downside to the unqualified ``memray`` script is that it's not immediately
clear what Python interpreter will be used to execute Memray. If you're using
a virtualenv that's not a problem because you know exactly what interpreter is
a virtual environment that's not a problem because you know exactly what interpreter is
in use, but otherwise you need to be careful to ensure that ``memray`` is
running with the interpreter you meant to use.

Expand Down
118 changes: 72 additions & 46 deletions docs/tutorials/1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ rest of the exercises. By the end of it, you should understand:

- Basic integration of Memray with pytest
- How to run a python script with Memray
- How to generate and interpret a flamegraph
- How to generate and interpret a flame graph

In this first example, we will be calculating and printing the results of a Fibonacci sequence of a
specified number of elements. Python has some great standardized practices for iterating over large
Expand Down Expand Up @@ -95,16 +95,16 @@ clicking the play button (circled in red below).
Local Virtualenv Test Execution
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Pytest is already installed in your docker image, so you can simply invoke it to execute your tests.
Run the following command in your docker image's shell, it will search all subdirectories for tests.
Pytest is already installed in your virtual environment, so you can simply invoke it to execute your tests.
Run the following command in your terminal, it will search all subdirectories for tests.
This will test the entire workshop.

.. code:: shell
pytest
This can be tedious to test everything when we are working on 1 example at a time. To save time,
let's specify the specific test we want to run.
It can be tedious to test all exercises when we are working on 1 exercise at a time. To save time,
let's run only the tests for exercise 1.

.. code:: shell
Expand All @@ -120,39 +120,45 @@ some additional information. Looks like our test case allocated more memory than
will be taking advantage of this amazing feature included with Memray to help run our workshop. Your
goal for each exercise will be to modify the exercises (NOT the tests), in order to respect these memory limits.

Flamegraphs, what are they?
---------------------------
Flame graphs, what are they?
----------------------------

OK, so we know our test is broken. How can we use Memray to help us dive deeper into the underlying
problem? The answer, is a flamegraph! A flamegraph is an HTML file that can be used to visualize how
your program utilizes memory at the point in time where the memory usage is at its peak.
problem? The answer is: a flame graph! A flame graph is a tool used to visualize the memory usage of
a program at a particular point in time. Memray can generate an HTML file that renders a *flamegraph
report*.

.. image:: ../_static/images/exercise1_flamegraph.png


On the middle portion of the screen, we can see the memory usage plotted vs time. The vertical (Y)
axis is memory used, and the horizontal (X) axis is time. Down below, a single moment in time (the
point when memory usage reached its peak) is plotted as a "flame graph". Each row in that flame
graph is a frame in your stack trace. The width of each box represents the relative amount of memory
used.
The *flamegraph report* is made up of three sections. At the top we have some controls to adjust the
appearance of our report. The middle portion of the screen shows a line plot where we can see total
memory usage of our program plotted over time. The vertical (Y) axis is memory used, and the
horizontal (X) axis is time. The bottom portion is the flame graph, which displays a snapshot of the
program's memory usage from only a single moment in time out of the entire execution runtime of the
program. By default, Memray generates reports showing the point when the program's memory usage
reached its peak. Each row in the flame graph is a frame in your stack trace. The width of each box
represents the relative amount of memory used. In *icicles* mode, the lowest row is the top of the
stack, and shows the functions that allocated memory, while in *flames* mode, the rows are flipped
such that the top row shows the top of the stack and is the location where memory is allocated.

You can click on a particular box to filter out less recent frames from the stack, focusing on a
particular frame and the functions it called into.

More details on :ref:`interpreting flame graphs` are available if this quick summary leaves you
confused.
More information on the :doc:`flame graph reporter <../flamegraph>` and how to
:ref:`interperet flame graphs <interpreting flame graphs>` are available in the docs.

Generating a flamegraph
-----------------------
Generating a Flame Graph
------------------------

Codespaces Flamegraph Generation
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Codespaces Flame Graph Generation
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

From VS Code, open up 2 terminals. You can do this by typing Ctrl+Shift+P (Cmd+Shift+P on macOS) to
open the "command palette", and then typing "terminal" in the search box and selecting "Python:
Create Terminal".

We need to launch an HTTP server to view our generated flamegraphs. Run this command in one of your
We need to launch an HTTP server to view our generated flame graphs. Run this command in one of your
terminals:

.. code:: shell
Expand All @@ -162,7 +168,7 @@ terminals:
You should now see a prompt to launch the application in your browser, and should click "Open in
Browser" in the bottom right. If that prompt doesn't appear, you can navigate to the *Ports* tab
(circled in orange below) and click the *Open in Browser* button (circled in green below). This will
give you an HTTP server we will use in order to launch and view our generated flamegraphs.
give you an HTTP server we will use in order to launch and view our generated flame graphs.

.. image:: ../_static/images/ports_tab.png

Expand All @@ -178,8 +184,8 @@ Run the first exercise labeled ``fibonacci.py``, but make sure to have Memray wr
memray run fibonacci.py
After the run is complete, Memray will conveniently print the command to generate a flamegraph from
the Memray output file.
After the run is complete, Memray will conveniently print the command to generate a flame graph from
the Memray output file. For example, we will run:

.. code:: shell
Expand All @@ -189,26 +195,26 @@ the Memray output file.

The run id will change each time you run the command.

Now that we have generated our flamegraph, let's load it up and have a look at it.
Now that we have generated our flame graph, let's load it up and have a look at it.
To do so, open the tab in your browser with your HTTP server, click on ``docs/tutorials/exercise_1``
directory, and then click on the flamegraph (it should have an html file extension)
directory, and then click on the flame graph (it should have an html file extension)

Voila! We have generated our very first flamegraph. Try clicking around the graph and exploring some
Voila! We have generated our very first flame graph. Try clicking around the graph and exploring some
of the features of Memray.

.. image:: ../_static/images/exercise1_flamegraph.png

Venv Flamegraph Generation
^^^^^^^^^^^^^^^^^^^^^^^^^^
Venv Flame Graph Generation
^^^^^^^^^^^^^^^^^^^^^^^^^^^

Run the first exercise labeled ``fibonacci.py``, but make sure to have Memray wrap this call.

.. code:: shell
memray run exercise_1/fibonacci.py
After the run is complete, Memray will conveniently print the command to generate a flamegraph from
the Memray output file.
After the run is complete, Memray will conveniently print the command to generate a flame graph from
the Memray output file. For example, we will run:

.. code:: shell
Expand All @@ -218,34 +224,54 @@ the Memray output file.

The run id will change each time you run the command.

Now that we have generated our flamegraph, you can launch the HTML output file in your web browser.
Now that we have generated our flame graph, you can launch the HTML output file in your web browser.

Challenge
---------

Take a closer look at the stack on the flamegraph - you will notice that the ``output.append`` line of
code appears to be the source of almost all of our script's allocations. Maybe that could be used as
Take a closer look at the stack on the flame graph — you will notice that the ``output.append`` call
appears to be the source of almost all of our script's allocations. Maybe that could be used as
a clue as to what in particular we may want to change to pass our test?

.. code-block:: python
:emphasize-lines: 13
def fibonacci(length):
# edge cases
if length < 1:
return []
if length == 1:
return [1]
if length == 2:
return [1, 1]
output = [1, 1]
for i in range(length - 2):
output.append(output[i] + output[i + 1]) # <- Here!
return output
Try to edit ``fibonacci.py`` to make the program more memory efficient. Test your solution by running
the ``test_exercise_1.py`` unit test, and inspect the effect your changes have on the memory allocation by
generating new flamegraphs. Ensure you don't break any of the correctness tests along the way!
generating new flame graphs. Ensure you don't break any of the correctness tests along the way!

.. raw:: html

<details>
<summary><i>Toggle to see the sample solution</i></summary>

After examining the flamegraph, we can see that the problem is caused by this intermediate array
After examining the flame graph, we can see that the problem is caused by this intermediate array
``output`` that we are using in order to capture and return the results of the calculation.

Python has an amazing construct that works perfectly in this situation called
Python has an amazing construct that works well in this situation called
`generators <https://wiki.python.org/moin/Generators>`_.

To explain it simply, a generator works by pausing execution of your function when you ``yield``,
and saving its state. After each iteration, we can return to that paused function in order to
retrieve the next value that is needed. This is much more memory efficient than processing the
entire loop and saving the results in memory — especially when you have 100,000 iterations! ::
Essentially, a generator works by pausing execution of your function when a ``yield`` statement is
reached, saving the state of the function for later. After each iteration, we can resume that paused
function in order to retrieve the next value that is needed. This is more memory efficient than
processing the entire loop and saving the results in memory — especially when you have 100,000
iterations! ::

def fibonacci(length):
# edge cases
Expand Down Expand Up @@ -274,14 +300,14 @@ Conclusion

We should try to avoid loading the entire result set into memory (like into a list) when we plan to
iterate on that result set anyways. This is especially true when your result set is very large. It is
typically best to work with generators in these types of cases.
typically best to work with generators in these types of situations.

.. note::

Sometimes it is better to do all the calculations up front. Generators are far more memory
efficient than lists, but iterating over generators is slightly slower than iterating over
lists, and generators can only be iterated over once. The best trade-off may vary from case to
case.
lists, and generators can only be iterated over once. The solution with the best trade-offs will
vary from case to case.

Using Memray's flamegraph can be a quick and easy way to identify where your applications memory usage
bottle neck is.
Using Memray's flame graph can be a quick and easy way to identify where your application has
a memory bottleneck.
33 changes: 16 additions & 17 deletions docs/tutorials/2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Exercise 2 - Clinging Onto Memory
Intro
-----

Unlike some low level languages like C, Python will manage our memory for us, and free up memory
Unlike some low level languages like C, Python will manage our memory for us and will free up memory
that's no longer needed. Python's automatic memory management makes our lives easier, but sometimes,
it may not work the way you would expect it to...

Expand All @@ -23,22 +23,21 @@ Take a guess, and then confirm by running ``memray`` and generating a ``flamegra
Expectations vs Reality
"""""""""""""""""""""""

Let's presume that we cant mutate the original data, the best we can do is peak memory of 200MB:
Let's presume that we can't mutate the original data, the best we can do is peak memory of 200MB:
for a brief moment in time both the original 100MB of data and the modified copy of the data will
need to be present. In practice, however, the actual peak usage will be 400MB as demonstrated by the
``flamegraph``:

.. image:: ../_static/images/exercise2_flamegraph.png

Examining our flamegraph further, we can see that we peak at 400MB of allocated memory due to three
allocations:

1. Original 100MB array created by ``load_xMb_of_data(100)``
2. The second modified array, created by ``raise_to_power()``
3. Inside ``add_scalar()``, the first modified array, created by ``data_pow()``, is held in memory
(100MB) until a scalar is added to it (another 100MB) and returned. Only once ``add_scalar()``
has returned, the 200MB used get deallocated together.
Examining our flame graph further, we can see that we peak at 400MB of allocated memory in
``add_scalar`` due to four 100MB allocations that are all alive simultaneously:

1. The return value from ``subtract_scalar``, held by the ``data`` variable in ``process_data``
2. The return value from ``raise_to_power``, held by the ``data_pow`` variable in ``process_data``
3. The return value from ``duplicate_data``, held by the ``data`` argument in ``add_scalar``
4. The return value from ``add_scalar``, which is created and populated before the function returns
and the ``data`` argument goes out of scope

Challenge
"""""""""
Expand All @@ -56,14 +55,14 @@ Solutions
<details>
<summary><i>Toggle to see the sample solutions</i></summary>

After examining the flamegraph, we can see that the problem is caused by local variables which are
After examining the flame graph, we can see that the problem is caused by local variables which are
no longer needed, but continue to use memory until ``process_data()`` has finished running.
Therefore, we need to refactor the method in a way that does not use unnecessary variables to store
data that will not be read afterwards. There are two main approaches we can use to solve our issue
here:

- Avoiding local variables in ``process_data()`` all together and instead returning the result of
nested function calls::
1. Avoiding local variables in ``process_data()`` all together and instead returning the result
of nested function calls::

def process_data():
# no extra reference to the original array
Expand All @@ -80,9 +79,9 @@ here:
ADD_AMOUNT
)

- Reassigning one variable: we can create a single variable, and re-use it multiple times to store
the new value of the manipulated array. This way, we will only hold one array in memory at a time,
instead of holding on to older versions of the mutated array unnecessarily::
2. Reassigning one variable: we can create a single variable, and re-use it multiple times to store
the new value of the manipulated array. This way, we will only hold one array in memory at a time,
instead of holding on to older versions of the mutated array unnecessarily::

def process_data():
# reusing the local variable instead of allocating more space
Expand All @@ -107,7 +106,7 @@ Conclusion
Typically, holding onto data in memory a little longer than needed is not a big issue. However, when
we are working with large objects, we should be particularly careful. Over-allocating unnecessary
memory can lead to running out of memory on the machine (especially for Linux VMs which are
typically smaller than the older physical machines).
typically smaller than physical machines).

Memray can be a helpful tool when trying to debug where we are over-allocating memory unnecessarily.

Expand Down
Loading

0 comments on commit 99919ef

Please sign in to comment.