Parallelization in python: Difference between revisions

From Stat DS Computing Wiki
Created page with " This wiki presents a motivational example of a lot of properties of python packages and how to use them. Please see packages motivation for motivation about why you shoul..."
 
No edit summary
 
(10 intermediate revisions by 2 users not shown)
Line 1: Line 1:


This wiki presents a motivational example of a lot of properties of python packages and how to use them. Please see [[packages motivation]] for motivation about why you should utilize the package framework while doing research. This material captures the basics presented by [https://py-pkgs.org/ py-pkgs], (recently) developed by UBC professors. As such you’ll find that python packages online may have different structures. Still, <code>py-pkgs</code> provides a lot of helpful tools to automate python package development. The <code>py-pkgs</code> project was actually started to mirror the helpful [[Developing_R_packages|package development tools in <code>R</code>]]. Note that this wiki page does provide additional commentary but the process proposed is very similar to <code>py-pkgs</code>’s approach.
[[Category:Parallelization]]


== Foundations (what tools will be used) ==
In Python, parallelization is a bit more of a “wild west” in terms of the number of parallelization packages available. This wiki will attempt to present minor examples of some common approaches, along with an outline of things to think about in selecting the library you end up using. In general, the process in Python involves:


In the package development pipeline that is proposed by <code>py-pkgs</code> we have a few very power packages to help you develop an <code>python</code> package as painlessly as possible.
# defining a function that captures what you want to have parallelized
# calling that function repeatedly in the the parallelization package way (which often looks like a list-recursive call).


* <code>poetry</code> help build a python package
There are a few different general frameworks to accomplish this.
* <code>cookiecutter</code> helps utilize pre-made templates (for our package’s files)
* <code>pytest</code> provides clean and standardized tools for testing
* (numpy style code) provides rubrics for documentation


== Building an python package ==
# '''General wrappers''' define the cores you’ll be using and then wrap that around the function call in a list-recursive way.
# '''Just in time compilers''', in addition to providing parallelization tools, provide decorators (e.g. <code>@jit</code>) for functions that allow for just in time compilation of the Python function into lower level machine code (like <code>C++</code>).


This process is slightly different than the one we demonstrated in the [[Developing_R_packages]] wiki (we set up the structure of the package and then link to github).
We will also discuss Python packages that are built specifically to parallelize jobs inside '''Jupyter notebooks'''.


=== Step 1: Creating a minimal source package ===
= Non-parallelized: Single Core Example =


A python (''source'') package just files in a directory, formatted in a specific way.
We naturally want to compare a parallelization approach with the base single core analysis, which can be done with the code as follows:


To create the bare bones python package, we will be using the packages <code>poetry</code> and <code>cookiecutter</code>, so if you haven’t already done so, install those two packages (we’ll be using <code>conda</code>) by typing:
<syntaxhighlight lang="python">#base for-loop
 
import numpy as np
<syntaxhighlight lang="bash">conda install -c conda-forge poetry cookiecutter</syntaxhighlight>
from sklearn.linear_model import LinearRegression
''Note that this line is taken directly from [https://py-pkgs.org/02-setup py-pkgs/02-setup], and it is possible that some day these packages can be installed wit ha simple <code>conda install poetry cookiecutter</code>.''
 
'''Now''', to actually start setting up your python package we will first use <code>cookiecutter</code> to copy a package structure provided by <code>py-pkgs</code> by running the following in our terminal:
 
<syntaxhighlight lang="bash">cookiecutter https://github.com/UBC-MDS/cookiecutter-ubc-mds.git</syntaxhighlight>
This will prompt you to answer a set of questions including
<ol>
<li> the author name (can include caps and spaces) </li>
<li> github username </li>
<li> package name (can include caps and spaces) </li>
<li> package slug name (can included underscores but no spaces) </li>
<li> a short package description (can include caps and spaces) </li>
<li> version number (we recommend either 0.0.1 or 0.1.0) </li>
<li> python version 8. open source license (they list the options) </li>
<li> if the package should include github actions (we suggest no for now, and plan to have wikis help with this later) </li>
</ol>
 
''Note: our package for this example will be named <code>tartanpy</code>'' (an a copy of the "final" product can be found on [https://github.com/benjaminleroy/tartanpy|github].
 
''Note: the cookiecutter template from <code>py-pkg</code>s (aka the one above) is still under construction - so it might look slightly different in the future.''
 
This will create a <code>tartanpy</code> folder with the following items:
 
# Directory <code>src/</code> (where all the function source code goes)
# Directory <code>docs/</code> (where all the pretty looking documentation goes '''- auto generated''')
# Directory <code>tests/</code> (where you’ll store your test, with a placeholder test file)
# <code>pyproject.toml</code> file (a description file about your package, which includes the things you entered when you ran the <code>cookiecutter</code> call)
# <code>LICENSE</code> (booler plate License - don’t really need to deal with this)
# A set of <code>.md</code> files for document things like general info for viewers (<code>README.md</code>), info for contributors and their conduct (<code>CONTRIBUTING.md</code> and <code>CONDUCT.md</code>) and a file to include information on changes you’ve made <code>CHANGELOG.md</code>).
 
[[File:Tartanpy_folder.png|frameless|600px|alt=initial directory items|initial directory items]]
 
==== pyproject.toml file meta-data ====
 
There are few ways for packages to store their meta-data, and <code>py-pkgs</code>’s path is through the <code>pyproject.toml</code>. Other options include things like <code>setup.py</code> files. If you made a mistake when answering questions via your cookiecutter prompts you can change them by opening up the <code>pyproject.toml</code> file. Our example package’s <code>pyproject.toml</code> file looks like the image below
 
[[File:Tartanpy_pyproject_description.png|frameless|600px|alt=initialized description file|initialized description file]]
 
In a <code>pyproject.toml</code> file you’ll notice that each section is headed by a <code>[info]</code> heading. Notice that three of the sections in the image above have <code>tool.poetry</code> headers. This means that the package <code>poetry</code> can update these sections. As we showcase the power of this package you may find that using poetry to update a lot of these fields is more desirable than manually doing it yourself.
 
=== Step 2: Setting up a Github repo ===
 
Version control (and discussed in Stat Computing) is a very useful tool to help streamline file management and provide piece of mind. Oftentimes, you will want to have a copy of the repository not just on your personal computer but online (to help with sharing, just in case your computer dies, it’s cool, etc.). As such these steps will walk you through how to set up a github repo for your new package and how to connect that repo to a new python package. This step will mirror thoughts in [https://py-pkgs.org/03-how-to-package-a-python#set-up-remote-version-control py-pkgs/03-how-to-package-a-python].
 
<ol style="list-style-type: decimal;">
<li><p>'''In <code>Github</code>''', do the following</p>
<ul>
<li><p>go to the top-level directory (i.e., <code>github.com/<your user name></code>)</p></li>
<li><p>click on “+” at top right, and select “New repository”</p></li>
<li><p>name the repository (e.g., “tartan_python”). Note that if you’d like to share your package with others via pypi you’ll need to make your package name unique!</p></li>
<li><p>provide a short description of the repository (don’t leave completely blank!)</p></li>
<li><p>keep the repository public (as students you have access to free private repos https://education.github.com/pack, but for purposes of this example we will keep the repo public)</p></li>
<li><p>'''do not''' initalize with a README, .gitignore, or license (It even says “Skip this step if you’re importing an exisiting repo”!!!)</p></li>
<li><p>click on “Create Repository”</p></li></ul>
</li>
<li><p>'''…then in your terminal, do the following'''</p>
<ul>
<li>First, initialize your repository with
 
<syntaxhighlight lang="bash">git init</syntaxhighlight>
<p>then add all <code>cookiecutter</code> files and create your initial commit with <code>bash  git add .  git commit -m "initial package setup"</code></p>
</li>
<li>setup connection to github by adding a a remote, your main branch and pushing your first commit with
 
<syntaxhighlight lang="bash">git remote add origin https://github.com/benjaminleroy/tartanpy.git
git branch -M main
git push -u origin main</syntaxhighlight>
<p>''Note that github provides you with walk throughs that mirror what was recommended above and showcases 2 approaches if you want to use the command line (<code>https</code> and <code>ssh</code>) as well as how you might do it with github desktop. So helpful - thanks github!''</p></li></ul>
 
</li></ol>
 
=== Step 3: Writing a function ===
 
All of your python code needs to go in <code>.py</code> files in the <code>src/tartanpy/</code> directory. Suppose our first set of functions deal with communicating to the user - then we might make a <code>communicate.py</code> file in <code>src/tartanpy/</code> folder. <br> Inside this file, let’s image we add the following function
 
<syntaxhighlight lang="python">def call_scotty_demo(your_name):
 
    name_length = len(your_name)
 
    # say Hiiiiii
    hi_string = "\n" +\
        "-" * (name_length + 8) + "\n" +\
        "| Hi " + your_name + "! | \n" +\
        "|  " + "-" * (name_length + 4) + "\n" +\
        "| /\n"
 
    scotty_string = ""+\
        " |/  |\\_/|          \n" +\
        " |  |q p|  /}      \n" +\
        " |  ( 0 )\"\"\"\\  \n" +\
        "  \\  |\"-\"`    |    \n" +\
        "    || /=\\\\  |    \n" +\
        "    \"'\"  '\"\"\"'"
 
    print(hi_string + scotty_string)
</syntaxhighlight>
To get access to this function we can use <code>poetry</code> to locally install our package with the bash command below (that needs to be run in the top folder of our package)
 
<pre>poetry install</pre>
''Note that without poetry this could be done with a <code>pip install -e .</code> in the same directory and this would actually not require running this line of code every time we update the package. On the other hand - we’ll show the versatility of <code>poetry</code> in the rest of the article.''
 
To utilize our function we can then open up a interactive python instance (e.g. <code>ipython</code> in your terminal) and then run
 
<syntaxhighlight lang="python">In [1]: from tartanpy import communicate
 
In [2]: communicate.call_scotty_demo("Andrew Carnegie")</syntaxhighlight>
which will return:
 
<syntaxhighlight lang="bash">-----------------------
| Hi Andrew Carnegie! |
|  -------------------
| /
|/  |\_/|
|  |q p|  /}
|  ( 0 )"""\
  \  |"-"`    |
    || /=\\  |
    "'"  '"""'</syntaxhighlight>
Note that <code>poetry install</code> can be called over and over if you keep updating your function files and will re-load the newest verion of all your functions.
 
==== Additional notes: ====
 
===== Understanding <code>__init__.py</code> =====
 
For <code>python</code> packages you’ll always have an <code>__init__.py</code> file in this directory. We currently have to import the <code>communicate</code> file from <code>tartanpy</code> in order to get access to our amazing function. To make this function come directly with the package, in the <code>__init__.py</code> file we can write
 
<syntaxhighlight lang="python">from .communicate import call_scotty_demo</syntaxhighlight>
''Note the dot in <code>.communicate</code> says “in this same directory as this current file”, and we don’t have to include the <code>.py</code> file link at the end.'' Additionally This approach '''only''' imports call_scotty_demo to the global package, we could use <code>... import *</code> to import all functions (but this approach is smart if we had helper functions we didn’t really need to share with the end user).*
 
With the above change, we can then do (the <code>poetry</code> part still needs to be at the top directory):
 
<syntaxhighlight lang="bash">poetry install
ipython</syntaxhighlight>
and then in the ipython shell:
 
<syntaxhighlight lang="python">In [1]: import tartanpy
 
In [2]: tartanpy.call_scotty_demo("Andrew Mellon")</syntaxhighlight>
which will return:
 
<syntaxhighlight lang="bash">---------------------
| Hi Andrew Mellon! |
|  -----------------
| /
|/  |\_/|
|  |q p|  /}
|  ( 0 )"""\
  \  |"-"`    |
    || /=\\  |
    "'"  '"""'</syntaxhighlight>
===== Using external packages =====
 
[You'll want to read the next section as well for a full understanding of dealing with external packages.]
 
Suppose I wished to truncate really long names I’m greeting but also give the user a warning that I’m going to do so. I’d need to use the <code>warnings</code> package. To use this in our package I’d need to change our <code>communicate.py</code> file to
 
<syntaxhighlight lang="python">import warnings
 
def call_scotty_demo(your_name):
    if len(your_name) > 30:
        warnings.warn("your name is beyond 30 characters, "+\
                        "has been truncated")
        your_name = your_name[:30]
 
    name_length = len(your_name)
 
    # say Hiiiiii
    hi_string = "\n" +\
        "-" * (name_length + 8) + "\n" +\
        "| Hi " + your_name + "! | \n" +\
        "|  " + "-" * (name_length + 4) + "\n" +\
        "| /\n"
 
    scotty_string = ""+\
        " |/  |\\_/|          \n" +\
        " |  |q p|  /}      \n" +\
        " |  ( 0 )\"\"\"\\  \n" +\
        "  \\  |\"-\"`    |    \n" +\
        "    || /=\\\\  |    \n" +\
        "    \"'\"  '\"\"\"'"
 
    print(hi_string + scotty_string)</syntaxhighlight>
Notice that we have to impot the <code>warnings</code> package at the top of our function file.
 
Now we have the additional funcitonality (after reloading with <code>poetry</code>)
 
<syntaxhighlight lang="python">In [1]: import tartanpy
 
In [2]: tartanpy.call_scotty_demo("Oski Bear, your a wonderful, but slightly creepy Berkeley Bear")</syntaxhighlight>
which returns


<syntaxhighlight lang="python">.../tartanpy/src/tartanpy/communicate.py:6: UserWarning: your name is beyond 30 characters, has been truncated
my_k = list(np.arange(14)+1)*20
  warnings.warn("your name is beyond 30 characters, "+\


--------------------------------------
def my_simulation(k):
| Hi Oski Bear, your a wonderful, b! |
|  ----------------------------------
| /
|/  |\_/|
|  |q p|  /}
|  ( 0 )"""\
  \  |"-"`    |
    || /=\\  |
    "'"  '"""'</syntaxhighlight>
==== Using external packages: declaring dependences & Using local functions in other files ====
 
<ol style="list-style-type: decimal;">
<li><p>'''Declaring dependencies''': The <code>warnings</code> module is actually a built in python package, but suppose we also needed to use <code>numpy</code> in our package. For example, suppose we created another <code>.py</code> file called <code>utils.py</code>, in it we had</p>
<syntaxhighlight lang="python">import numpy as np
 
def _top_and_bottom(name_length):
    length_vec = np.array([8,4]) + name_length
    return(length_vec)</syntaxhighlight>
<p>The first thing we’d need to do (so the code works for others) is to tell the package we require the installation of <code>numpy</code> for anyone using our package. We do so by running</p>
<syntaxhighlight lang="bash">poetry add numpy</syntaxhighlight>
<p>This will update our <code>pyproject.toml</code> and add asssociated dependences (there are none for <code>numpy</code>), and will put that note under <code>[tool.poetry.dependencies]</code>.</p>
<p>''Note: as of 8/20/21 this also required restricting my python version (in the <code>pyproject.toml</code> to <code>python = "?=3.8,<3.11"</code>)</p></li>
<li><p>'''Internal Functions''': To use our interal function <code>_top_and_bottom</code> we might change the <code>communicate.py</code> file to:</p>
<syntaxhighlight lang="python">import warnings
from .utils import _top_and_bottom
 
def call_scotty_demo(your_name):
    # import warnings
    if len(your_name) > 30:
        warnings.warn("your name is beyond 30 characters, "+\
                        "has been truncated")
        your_name = your_name[:30]
 
    name_length = len(your_name)
 
    tb_info = _top_and_bottom(name_length)
 
    # say Hiiiiii
    hi_string = "\n" +\
        "-" * tb_info[0] + "\n" +\
        "| Hi " + your_name + "! | \n" +\
        "|  " + "-" * tb_info[1] + "\n" +\
        "| /\n"
 
    ...</syntaxhighlight>
<p>Notice that to give <code>call_scotty_demo</code> and all functions in the <code>communicate.py</code> file access to <code>_top_and_bottom</code> we had to import it using <code>from .utils import _top_and_bottom</code>. Additionally, it’s best practice to use add a <code>_</code> at the beginning of a function name if you plan to keep it internal.</p></li></ol>
 
=== Step 4: Documenting your functions ===
 
Python documentation is simplier than R documentation as when you look at the documentation of a python function you just get show a docstring at the head of function. In this section we’ll discuss a bit about the extra support poetry can do so that you can generate a localized website for your documentation (that you could upload to [https:://readthedocs.org readthedocs] later).
 
For documentation to be easily publishable at a later date we recommend that your docstrings follow the <code>numpydoc</code> format (more details can be found at [https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard numpydoc.readthedocs]). Using this guideline we can document <code>call_scotty_demo</code> as follows:
 
<syntaxhighlight lang="python">def call_scotty_demo(your_name):
     """
     """
     Greet Scotty, the Scottie Dog
     estimate the polynomial fit of degree k's MSE for a linear
 
     relationship
     Scotty, the Scottie Dog greets your by name!


     Parameters
     Parameters
     ----------
     ----------
     your_name: str
     k : int
        string of your name (max length 30 characters)
    degree of polynomial fit


     Returns
     Returns
     -------
     -------
     None
     mse of fit based on 30 points applied to a test set of 10000 points
        Though scotty appears on your screen and says Hi to you.
 
    Examples
    --------
    >>> import tartanpy
    >>> tartanpy.call_scotty_demo("Andrew Carnegie")
 
    -----------------------
    | Hi Andrew Carnegie! |
    |  -------------------
    | /
    |/  |\_/|
    |  |q p|  /}
    |  ( 0 )\"\"\"\\
    \\  |\"-\"`    |
        || /=\\\\  |
        \"'\"  '\"\"\"'
     """
     """
     ...</syntaxhighlight>
     x = np.random.uniform(size=30,low=-3,high=3)
The basic structure of the documention is decently straight forward with the title / description of your function first, followed by (1) Parameters header with the parameter name, the type, and the description in the format above (2) Returns header followed by similar structure as Parameters. Adding (3) examples with standard python lines can be helpful as well.
    y = 2*x + 2* np.random.normal(size=30)
    y = y.reshape(-1,1)
    X_poly = np.concatenate([x.reshape(-1,1)**kk
                          for kk in np.arange(1,k+1,dtype = int)],
                          axis = 1)


After reloading the package you could see that


<syntaxhighlight lang="python">In [1]: import tartanpy
    x0 = np.random.uniform(size=10000,low=-3,high=3)
    y0 = 2*x0 + 2*np.random.normal(size=10000)
    y0 = y0.reshape(-1,1)
    X0_poly = np.concatenate([x0.reshape(-1,1)**kk
                          for kk in np.arange(1,k+1,dtype = int)],
                          axis = 1)


In [2]: ?tartanpy.call_scotty_demo
    my_lm = LinearRegression().fit(X_poly,y)
Signature: tartanpy.call_scotty_demo(your_name)
    y0_hat = my_lm.predict(X0_poly)
Docstring:
Greet Scotty, the Scottie Dog


Scotty, the Scottie Dog greets your by name!
    return(np.mean((y0-y0_hat)**2))
</syntaxhighlight>
<syntaxhighlight lang="python">import time


Parameters
starttime = time.time()
----------
[my_simulation(k) for k in my_k]
your_name: str
print(str(time.time() - starttime) + " seconds")</syntaxhighlight>
    string of your name (max length 30 characters)
<syntaxhighlight lang="python">0.9454693794250488 seconds</syntaxhighlight>
= General Python Parallelization Packages =


Returns
As previously mentioned, there are a lot of packages for general parallelization. Two commonly used ones are <code>joblib</code> and <code>multiprocessing</code>. <code>joblib</code> looks a bit more like list-recursion and <code>multiprocessing</code> uses functions like <code>.map()</code>.  
-------
None
    Though scotty appears on your screen and says Hi to you.


Examples
== <code>multiprocessing</code>/<code>multiprocess</code> ==
--------
>>> import tartanpy
>>> tartanpy.call_scotty_demo("Andrew Carnegie")
-----------------------
| Hi Andrew Carnegie! |
|  -------------------
| /
|/  |\_/|
|  |q p|  /}
|  ( 0 )"""\
\  |"-"`    |
    || /=\\  |
    "'"  '"""'
File:      ..../tartanpy/src/tartanpy/communicate.py
Type:      function</syntaxhighlight>
==== Towards "readthedocs"-style html documentation ====


py-pkgs and poetry also provide a quick way to transform your documentation into a local html website so you can quickly look through your documentation or load it to readthedocs later, with information on how to here: [https://py-pkgs.org/06-documentation py-pkgs/06-documentation].
The <code>multiprocessing</code> package is actually built into Python, and works in a pretty straightforward manner. It also allows for synchronous vs. asynchronous parallelization, where synchronous means the order of the jobs must be followed, one after the next, and asynchronous means multiple tasks can be executed in small parts in overlapping periods. Note that many statistical uses of parallelization do not need synchronous parallelization.


Generally speaking you have to
Since both approaches have the same syntax we’ll be using <code>multiprocess</code> (as it works with <code>ipython</code> and <code>jupyter notebooks</code>). <code>multiprocessing</code> can have problems with <code>ipython</code> and <code>jupyter notebook</code>s. In <code>jupyter notebook</code>s, if you want to use <code>multiprocessing</code> , it needs to be wrapped in <code>if name == "main"</code> to properly fork processes; you can also save your process is as a .py file and import it into your <code>jupyter notebook.</code>


<ol>
=== Implementation ===
<li> install some additional packages to the development side of your package with


<syntaxhighlight lang="bash">poetry add --dev sphinx nbsphinx ipykernel</syntaxhighlight> </li>
First, you need to identify the number of cores you have:


<li>and then go to your docs folder and make the website locally (the <code>--dev</code> parameter makes these packages not tracked for those just wanted to use your package, but required as you continue to develop the package locally).
<syntaxhighlight lang="python">import multiprocess as mp
#Could also using import multiprocessing as mp; the syntax is the same


<syntaxhighlight lang="bash">cd docs
num_cores = mp.cpu_count()</syntaxhighlight>
poetry run make html</syntaxhighlight>
==== Synchronous  ====
and this will create a local webpage (just open <code>docs/index.html</code>) that contains your documentation. If you find the docs website useful maybe bookmark the local file?


</li></ol>
<syntaxhighlight lang="python">starttime = time.time()
with mp.Pool(processes=num_cores-2) as p:
  results = p.map(my_simulation, my_k)
print(str(time.time() - starttime) + " seconds")</syntaxhighlight>
<syntaxhighlight lang="python">0.7638578414916992 seconds</syntaxhighlight>
==== Asynchronous ====


=== Step 5: Testing ===
<syntaxhighlight lang="python">starttime = time.time()
with mp.Pool(processes=num_cores-2) as p:
    results = p.map_async(my_simulation, my_k).get()
print(str(time.time() - starttime) + " seconds")
    </syntaxhighlight>
<syntaxhighlight lang="python">0.8183681964874268 seconds</syntaxhighlight>
== <code>joblib</code> ==


Testing is critical to making sure your functions do what you expect. There are few ways to write unit tests in python (e.g. <code>unittest</code>, <code>pytest</code> and <code>nose</code>). We recommend using <code>pytest</code> as it requires the least amount of overhead and provides the most details write off the bat.
The <code>joblib</code> package is pretty straightforward and requires you to structure things in terms of list-recursion with a few wrappers to help the process work as desired. Just like with <code>multiprocessing</code>, the number of cores needs to be computed before proceeding.


To use <code>pytest</code> we first to tell <code>poetry</code> that we’ll use <code>pytest</code> in our development with
=== Implementation ===


<syntaxhighlight lang="bash">poetry add --dev pytest</syntaxhighlight>
<syntaxhighlight lang="python">import joblib
==== What’s the philosophy behind <code>pytest</code> ====


<ol>
num_cores = joblib.cpu_count()</syntaxhighlight>To use <code>joblib</code> functionality, we can import the functions <code>Parallel</code> and <code>delayed</code>. <code>Parallel</code> creates an instance for parallelization with a set number of jobs based on the core availability.  <code>delayed</code> creates deferred function calls to distribute computations across multiple cores; this is the function that allows for simultaneous task execution.
<li>'''Assertions''': <code>pytest</code>’s foundation is defined around checking basic assertion statements like


<syntaxhighlight lang="python">In [1]: assert 1 == 1, "a true statement shouldn't have errored..."
<syntaxhighlight lang="python">from joblib import Parallel, delayed</syntaxhighlight>
  ...: assert .3 - .2 == .1, "but sometimes floating points are bad..."</syntaxhighlight>
We then can move our list-recursive example into our <code>joblib</code> structure. We wrap <code>Parallel(...)()</code> around the inside of the list-recursion and <code>delayed()</code> around the functions that will be repeated multiple times.
that return:


<syntaxhighlight lang="python">---------------------------------------------------------------------------
<syntaxhighlight lang="python">starttime = time.time()
AssertionError                            Traceback (most recent call last)
r = Parallel(n_jobs=num_cores-2)(delayed(my_simulation)(k) for k in my_k)
<ipython-input-3-0daf5f7e9b9c> in <module>
print(str(time.time() - starttime) + " seconds")</syntaxhighlight>
      1 assert 1 == 1, "a true statement shouldn't have errored..."
<syntaxhighlight lang="python">0.5306367874145508 seconds</syntaxhighlight>
----> 2 assert .3 - .2 == .1, "but sometimes floating points are bad..."
=== Add-ons ===


AssertionError: but sometimes floating points are bad...</syntaxhighlight>
There are multiple add-ons that can be desirable
as the second line actual fails (due to some floating point silliness). This simple structure can be combined with standard logical <code>and</code> and <code>or</code> as well as other tools like <code>numpy</code> logic (.e.g <code>np.all()</code>). Notice that following a logical statement with a string nicely associated a statement to be returned if the assertion is not true.
</li>
<li>'''Grouping Assertions''': We can imagine wishing to combine a set of assertions, and we can do this easily with a function wrapper. For example


<syntaxhighlight lang="python">import tartanpy
<ol style="list-style-type: decimal;">
import pytest # only for warning
<li><p>'''Verbosity''' can be added with a single parameter use (as seen below). This will provide more detailed output information.</p>
<syntaxhighlight lang="python">r = Parallel(n_jobs=num_cores-2, verbose=10)(delayed(my_simulation)(k) for k in my_k)</syntaxhighlight></li>
<li><p>'''Multiple parameters''' can be passed with redefinitions of functions to take groupings. To replace multiple for-loops, often the function <code>itertools.product()</code> is used for the parameters.</p></li></ol>


def test_call_scotty_demo():
== <code>ray</code> ==
    """
The package <code>ray</code> is an open source project for parallel and distributed computing in Python. <code>ray</code> is often preferred over <code>multiprocess/multiprocessing</code> because of its efficient machine error handling methodology and its ability to handle larger data types. <code>ray</code> allows for aggregation of results/values, meaning the output of one task can be used as input for subsequent tasks seamlessly. The implementation for <code>ray</code> is not dissimilar from that of <code>multiprocess</code>.
    test the basics of call_scotty_demo
    """


     assert tartanpy.call_scotty_demo("Oski Bear") is None, \
=== Implementation ===
        "the output of call_scotty_demo should be None"
<syntaxhighlight lang="python">import ray
import os</syntaxhighlight>In order to parallelize computation with <code>ray</code>, you must first initialize an instance of <code>ray</code>. Just like with <code>multiprocess</code>, the instance relies on the number of CPUs available.<syntaxhighlight lang="python">
ray.init(num_cpus = os.cpu_count())
</syntaxhighlight>Additionally, in order for <code>ray</code> to work, the function <code>my_simulation</code> needs to have the decorator <code>@ray.remote</code> prior to its heading. <syntaxhighlight lang="python">
@ray.remote
def my_simulation(k):
     """the rest of the function code goes here"""
</syntaxhighlight>Once we have initialized an instance based on our number of available CPUs, and we have made sure our function has the proper decorator, we can parallelize computation as follows. The <code>.remote()</code> passes the variable into the function, even if the previous computation has not finished running, and the <code>.get()</code> compiles the results once finished.<syntaxhighlight lang="python">
starttime = time.time()
futures = [my_simulation.remote(k) for k in my_k]
results = ray.get(futures)
print(str(time.time() - starttime) + " seconds")
</syntaxhighlight>This <code>.remote()</code> piece is useful because you can pass previous computations into it in order to have a parallelized, autoregressive work flow.<syntaxhighlight lang="python">
0.13494515419006348 seconds
</syntaxhighlight>


    # test warning
= Jupyter Notebook: <code>ipyparallel</code>=
    with pytest.warns(UserWarning) as record:
        tartanpy.call_scotty_demo("Oski Bear, your a wonderful, but slightly creepy Berkeley Bear")


    assert len(record) > 0, \
For those that heavily use Jupyter notebooks, the package <code>ipyparallel</code> works quite nicely. Some benefits of this package include its verbosity, its ability to serialize numpy arrays cleanly, and the fact that it can distribute across multiple machines. This package also works with <code>numba.jit</code> and <code>cython</code>.
        "expect a warning for name_string longer than 30 for call_scotty_demo"


    assert .3 - .2 == .1, "but sometimes floating points are bad..."</syntaxhighlight>
Before opening your <code>jupyter notebook</code> you’ll want to make sure the extension is enabled - which can be done from the command line with the following command or inside the <code>jupyter notebook</code> extensions tab.
</li>
<li>To actually leverage <code>pytest</code> we can put '''A set of grouped tests''' in single file. When building a package your functions will likelyl live in different <code>.py</code> files in the <code>src/<packagename></code> folder. To mirror this structure we put of our tests associated with a single <code>.py</code> file into 1 test file. These files sit in the <code>tests/</code> folder and must start with <code>test</code>.


In these larger files (relative to group functions) need to make sure our functions are brought in, for example at the head we need to put <code>import tartanpy</code> as see in the code block above.
<syntaxhighlight lang="bash">ipcluster nbextension enable</syntaxhighlight>
You’ll then need to go to the “IPython Clusters” tab in <code>jupyter noteook</code> and start some parallel clusters.


To run these tests you can either do
==<code>ipyparallel</code> parallelization ==


<syntaxhighlight lang="bash">pytest</syntaxhighlight>
<syntaxhighlight lang="python">import ipyparallel as ipp</syntaxhighlight>
or (making sure you have all the correct packages)
For direct execution we define <code>dv</code> and provide it with what it needs.


<syntaxhighlight lang="bash">poetry run pytest</syntaxhighlight>
<syntaxhighlight lang="python">rc = ipp.Client()
''Note: we actually have some printouts in <code>call_scotty_demo</code>, which are not desirable, so we actually follow a suggestion from [https://stackoverflow.com/questions/8391411/how-to-block-calls-to-print stackoverflow] to suppress the printing (doesn’t mess with the testing), and is seen in the code block below:''
dv = rc[:] # direct view</syntaxhighlight>
=== Passing Things to Each Cluster ===


<syntaxhighlight lang="python">import tartanpy
<code>ipyparallel</code> provides ways for the user to pass only the necessary items to the the clusters as to not overload them.
import pytest # only for warning


def test_call_scotty_demo():
<ol style="list-style-type: decimal;">
    """
<li><p>Passing packages all together</p>
    test the basics of call_scotty_demo
<syntaxhighlight lang="python">with dv.sync_imports():
    """
     from sklearn.linear_model import LinearRegression
    with open(os.devnull, "w") as f, contextlib.redirect_stdout(f):
     # getting dependencies on the clusters</syntaxhighlight></li>
        assert tartanpy.call_scotty_demo("Oski Bear") is None, \
<li><p>Passing variables:</p>
            "the output of call_scotty_demo should be None"
<p>Below is just an example - we don’t need to pass <code>my_k</code> since <code>dv</code> will directly get it when we want to use it.</p>
 
<syntaxhighlight lang="python">dv["my_k"] = my_k</syntaxhighlight></li>
        # test warning
<li><p>We can also use the <code>%px</code> magick to run lines of code on all servers.</p>
        with pytest.warns(UserWarning) as record:
<syntaxhighlight lang="python">%px import numpy as np</syntaxhighlight></li></ol>
            tartanpy.call_scotty_demo("Oski Bear, your a wonderful, but slightly creepy Berkeley Bear")
 
        assert len(record) > 0, \
            "expect a warning for name_string longer than 30 for call_scotty_demo"
 
 
 
        assert .3 - .2 == .1, "but sometimes floating points are bad..."</syntaxhighlight>
If we remove the silly floating point error assession we get the following:
 
[[File:Pytest_example.png|frameless|600px|alt=output of pytest|output of pytest]]
 
And if there are errors <code>pytest</code> tries to be very clear about where in your tests you had an error.
</li></ol>
 
==== More comments on testing ====
 
<ol>
<li>'''Hidden and/or not exported functions'''
 
In our example we have a hidden function <code>_top_and_bottom</code>. Different thoughts exist on whether or not one should test hidden functions, but to do so we have to be a bit tricker since the function isn’t loaded by the package. We can still test it by using the full path to the function, that is:
 
<syntaxhighlight lang="python">import tartanpy
import numpy as np
 
def test__top_and_bottom():
     """
    test for hidden function _top_and_bottom
    """
    option4 = tartanpy.utils._top_and_bottom(len("test"))
     option17 = tartanpy.utils._top_and_bottom(len("test_a_longer_one"))
 
    assert np.all(option17 - option4 == 17-4), \
        "expect _top_and_bottom values to only differ by length of input"
 
    assert np.all(option17 - 17 == np.array([8,4])), \
        "expect that _top_and_bottom adds 8 and 4 to length of input"
 
</syntaxhighlight>
Additionally, since we started the name off with a <code>_</code>, we could also find that if we did
 
<syntaxhighlight lang="python">from tartanpy.utils import *</syntaxhighlight>
which should import everything from the <code>util.py</code> file we won’t actually have access to it, whereas
 
<syntaxhighlight lang="python">from tartanpy.communicate import *</syntaxhighlight>
would get access to <code>call_scotty_demo</code> even if it wasn’t in the <code>__init__.py</code> file.
 
</li>
<li>'''Numerical instablity'''
 
We included an example where floating point differnces made <code>.3-.2</code> not equal to <code>.1</code>. To avoid such errors we recommend using tools like
 
* <code>np.allclose</code> (which allows user to provide relative and absolute tolerance between vectors)
* or do something like <code>np.abs(a-b)<1e-08</code> or <code>np.abs(a-b)/np.abs(b) < 1e-08</code>
</li></ol>


=== Step 6. Sharing / using on other computers ===
=== Actual Functionality ===


You could publish your package to pypi (documented here: [https://py-pkgs.org/07-releasing-versioning py-pkg/07-releasing-versioning]) but if you’re still actively working on a developer version another easy way is to make sure you have a clean copy on github, have the user clone the repo and then run either (when in the top directory of the cloned repo)
The actual functionality is very similar to <code>multiprocess</code>/<code>multiprocessing</code> with <code>maps</code> and <code>apply</code>.


<syntaxhighlight lang="bash">pip install .</syntaxhighlight>
==== Synchronous ====
or


<syntaxhighlight lang="bash">pip install -e .</syntaxhighlight>
<syntaxhighlight lang="python">%%timeit
where the first installs the package 1 time, and the second actually installs the package and updates the package if that user was to do more pulls / updates the the package.
results = dv.map_sync(my_simulation, my_k)</syntaxhighlight>
<syntaxhighlight lang="python">201 ms ± 34.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)</syntaxhighlight>
==== Asynchronous ====
For asynchronous, you need to use <code>.get()</code> in order to get the results at a later time/when they come in, since the work is not done in order and is instead chunked for efficiency.<syntaxhighlight lang="python">%%timeit
results = dv.map_async(my_simulation, my_k).get() </syntaxhighlight>
<syntaxhighlight lang="python">209 ms ± 28.1 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)</syntaxhighlight>
= Just in Time Compilers + Parallelization =


Just-in-time compilation is the compiling of your computer code during execution instead of beforehand. This means bytecode is not translated to machine code until run time, and is then executed directly. Code is continuously analyzed to determine where compilation/recompilation would speed up performance. Used in conjunction with parallelization, this is an efficient way to improve computational speed.


== <code>numba</code> ==
<code>numba</code> is a high performance Python compiler that translates Python/numpy code into fast, efficient machine code. Python is traditionally regarded as a "slow" language; using <code>numba</code>, you can approach the speeds of languages such as C. <code>numba</code> is specifically designed to work well with numpy arrays.


=== Step 7. And beyond! ===
=== Implementation ===
Similar to <code>ray</code>, <code>numba</code> requires a decorator <code>@njit (parallel = True)</code> before the function. We will define a different function to be parallelized here to demonstrate <code>numba</code> <code>prange</code> which allows for efficient parallelization across loops.<syntaxhighlight lang="python">
A = np.random.rand(3, 3)
B = np.random.rand(3, 3)


In the above example, we still didn’t cover a lot of things, including:
@njit(parallel = True)
def my_simulation(A, B):
    C = np.empty_like(A)
    for i in prange(A.shape[0]):
        for j in range(A.shape[1]):
            C[i, j] = A[i, j] + B[i, j]
    return C
</syntaxhighlight><code>prange</code> should not be nested and should only ever be used in the outer loop. This is a very simple example, but for computationally complex looping problems, <code>numba</code> is highly efficient. However, you need to ensure that you are not creating or declaring a variable within a loop that cannot be accessed outside of the loop, or else parallelization will not work.


*publishing the package
= Additional Topics and Resources =
*publishing the docs
*including data
*CI/CD (which will be provided in another wiki)


See [https://py-pkgs.org py-pkgs] for some good starting blocks for most of these.
# [https://people.duke.edu/~ccc14/sta-663-2018/notebooks/S14A_Parallel_Programming_Introduction.html Duke’s statistical computing course] provides a set of examples for multiple Python parallelization packages

Latest revision as of 02:34, 30 June 2026


In Python, parallelization is a bit more of a “wild west” in terms of the number of parallelization packages available. This wiki will attempt to present minor examples of some common approaches, along with an outline of things to think about in selecting the library you end up using. In general, the process in Python involves:

  1. defining a function that captures what you want to have parallelized
  2. calling that function repeatedly in the the parallelization package way (which often looks like a list-recursive call).

There are a few different general frameworks to accomplish this.

  1. General wrappers define the cores you’ll be using and then wrap that around the function call in a list-recursive way.
  2. Just in time compilers, in addition to providing parallelization tools, provide decorators (e.g. @jit) for functions that allow for just in time compilation of the Python function into lower level machine code (like C++).

We will also discuss Python packages that are built specifically to parallelize jobs inside Jupyter notebooks.

Non-parallelized: Single Core Example

We naturally want to compare a parallelization approach with the base single core analysis, which can be done with the code as follows:

#base for-loop
import numpy as np
from sklearn.linear_model import LinearRegression

my_k = list(np.arange(14)+1)*20

def my_simulation(k):
    """
    estimate the polynomial fit of degree k's MSE for a linear
    relationship

    Parameters
    ----------
    k : int
    degree of polynomial fit

    Returns
    -------
    mse of fit based on 30 points applied to a test set of 10000 points
    """
    x = np.random.uniform(size=30,low=-3,high=3)
    y = 2*x + 2* np.random.normal(size=30)
    y = y.reshape(-1,1)
    X_poly = np.concatenate([x.reshape(-1,1)**kk
                          for kk in np.arange(1,k+1,dtype = int)],
                          axis = 1)


    x0 = np.random.uniform(size=10000,low=-3,high=3)
    y0 = 2*x0 + 2*np.random.normal(size=10000)
    y0 = y0.reshape(-1,1)
    X0_poly = np.concatenate([x0.reshape(-1,1)**kk
                          for kk in np.arange(1,k+1,dtype = int)],
                          axis = 1)

    my_lm = LinearRegression().fit(X_poly,y)
    y0_hat = my_lm.predict(X0_poly)

    return(np.mean((y0-y0_hat)**2))
import time 

starttime = time.time()
[my_simulation(k) for k in my_k]
print(str(time.time() - starttime) + " seconds")
0.9454693794250488 seconds

General Python Parallelization Packages

As previously mentioned, there are a lot of packages for general parallelization. Two commonly used ones are joblib and multiprocessing. joblib looks a bit more like list-recursion and multiprocessing uses functions like .map().

multiprocessing/multiprocess

The multiprocessing package is actually built into Python, and works in a pretty straightforward manner. It also allows for synchronous vs. asynchronous parallelization, where synchronous means the order of the jobs must be followed, one after the next, and asynchronous means multiple tasks can be executed in small parts in overlapping periods. Note that many statistical uses of parallelization do not need synchronous parallelization.

Since both approaches have the same syntax we’ll be using multiprocess (as it works with ipython and jupyter notebooks). multiprocessing can have problems with ipython and jupyter notebooks. In jupyter notebooks, if you want to use multiprocessing , it needs to be wrapped in if name == "main" to properly fork processes; you can also save your process is as a .py file and import it into your jupyter notebook.

Implementation

First, you need to identify the number of cores you have:

import multiprocess as mp 
#Could also using import multiprocessing as mp; the syntax is the same

num_cores = mp.cpu_count()

Synchronous

starttime = time.time()
with mp.Pool(processes=num_cores-2) as p:
  results = p.map(my_simulation, my_k)
print(str(time.time() - starttime) + " seconds")
0.7638578414916992 seconds

Asynchronous

starttime = time.time()
with mp.Pool(processes=num_cores-2) as p:
    results = p.map_async(my_simulation, my_k).get() 
print(str(time.time() - starttime) + " seconds")
0.8183681964874268 seconds

joblib

The joblib package is pretty straightforward and requires you to structure things in terms of list-recursion with a few wrappers to help the process work as desired. Just like with multiprocessing, the number of cores needs to be computed before proceeding.

Implementation

import joblib

num_cores = joblib.cpu_count()

To use joblib functionality, we can import the functions Parallel and delayed. Parallel creates an instance for parallelization with a set number of jobs based on the core availability. delayed creates deferred function calls to distribute computations across multiple cores; this is the function that allows for simultaneous task execution.

from joblib import Parallel, delayed

We then can move our list-recursive example into our joblib structure. We wrap Parallel(...)() around the inside of the list-recursion and delayed() around the functions that will be repeated multiple times.

starttime = time.time()
r = Parallel(n_jobs=num_cores-2)(delayed(my_simulation)(k) for k in my_k)
print(str(time.time() - starttime) + " seconds")
0.5306367874145508 seconds

Add-ons

There are multiple add-ons that can be desirable

  1. Verbosity can be added with a single parameter use (as seen below). This will provide more detailed output information.

    r = Parallel(n_jobs=num_cores-2, verbose=10)(delayed(my_simulation)(k) for k in my_k)
  2. Multiple parameters can be passed with redefinitions of functions to take groupings. To replace multiple for-loops, often the function itertools.product() is used for the parameters.

ray

The package ray is an open source project for parallel and distributed computing in Python. ray is often preferred over multiprocess/multiprocessing because of its efficient machine error handling methodology and its ability to handle larger data types. ray allows for aggregation of results/values, meaning the output of one task can be used as input for subsequent tasks seamlessly. The implementation for ray is not dissimilar from that of multiprocess.

Implementation

import ray
import os

In order to parallelize computation with ray, you must first initialize an instance of ray. Just like with multiprocess, the instance relies on the number of CPUs available.

ray.init(num_cpus = os.cpu_count())

Additionally, in order for ray to work, the function my_simulation needs to have the decorator @ray.remote prior to its heading.

@ray.remote
def my_simulation(k):
    """the rest of the function code goes here"""

Once we have initialized an instance based on our number of available CPUs, and we have made sure our function has the proper decorator, we can parallelize computation as follows. The .remote() passes the variable into the function, even if the previous computation has not finished running, and the .get() compiles the results once finished.

starttime = time.time()
futures = [my_simulation.remote(k) for k in my_k]
results = ray.get(futures)
print(str(time.time() - starttime) + " seconds")

This .remote() piece is useful because you can pass previous computations into it in order to have a parallelized, autoregressive work flow.

0.13494515419006348 seconds

Jupyter Notebook: ipyparallel

For those that heavily use Jupyter notebooks, the package ipyparallel works quite nicely. Some benefits of this package include its verbosity, its ability to serialize numpy arrays cleanly, and the fact that it can distribute across multiple machines. This package also works with numba.jit and cython.

Before opening your jupyter notebook you’ll want to make sure the extension is enabled - which can be done from the command line with the following command or inside the jupyter notebook extensions tab.

ipcluster nbextension enable

You’ll then need to go to the “IPython Clusters” tab in jupyter noteook and start some parallel clusters.

ipyparallel parallelization

import ipyparallel as ipp

For direct execution we define dv and provide it with what it needs.

rc = ipp.Client()
dv = rc[:] # direct view

Passing Things to Each Cluster

ipyparallel provides ways for the user to pass only the necessary items to the the clusters as to not overload them.

  1. Passing packages all together

    with dv.sync_imports():
        from sklearn.linear_model import LinearRegression
        # getting dependencies on the clusters
  2. Passing variables:

    Below is just an example - we don’t need to pass my_k since dv will directly get it when we want to use it.

    dv["my_k"] = my_k
  3. We can also use the %px magick to run lines of code on all servers.

    %px import numpy as np

Actual Functionality

The actual functionality is very similar to multiprocess/multiprocessing with maps and apply.

Synchronous

%%timeit
results = dv.map_sync(my_simulation, my_k)
201 ms ± 34.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Asynchronous

For asynchronous, you need to use .get() in order to get the results at a later time/when they come in, since the work is not done in order and is instead chunked for efficiency.

%%timeit
results = dv.map_async(my_simulation, my_k).get()
209 ms ± 28.1 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

Just in Time Compilers + Parallelization

Just-in-time compilation is the compiling of your computer code during execution instead of beforehand. This means bytecode is not translated to machine code until run time, and is then executed directly. Code is continuously analyzed to determine where compilation/recompilation would speed up performance. Used in conjunction with parallelization, this is an efficient way to improve computational speed.

numba

numba is a high performance Python compiler that translates Python/numpy code into fast, efficient machine code. Python is traditionally regarded as a "slow" language; using numba, you can approach the speeds of languages such as C. numba is specifically designed to work well with numpy arrays.

Implementation

Similar to ray, numba requires a decorator @njit (parallel = True) before the function. We will define a different function to be parallelized here to demonstrate numba prange which allows for efficient parallelization across loops.

A = np.random.rand(3, 3)
B = np.random.rand(3, 3)

@njit(parallel = True)
def my_simulation(A, B):
    C = np.empty_like(A)
    for i in prange(A.shape[0]):
        for j in range(A.shape[1]):
            C[i, j] = A[i, j] + B[i, j]
    return C

prange should not be nested and should only ever be used in the outer loop. This is a very simple example, but for computationally complex looping problems, numba is highly efficient. However, you need to ensure that you are not creating or declaring a variable within a loop that cannot be accessed outside of the loop, or else parallelization will not work.

Additional Topics and Resources

  1. Duke’s statistical computing course provides a set of examples for multiple Python parallelization packages