github twitter keybase instagram spotify

Development Setup for an M1 Mac

This post is a bit divergent from my usual write ups, but I know a few people off hand that are looking to upgrade their macs, and are a bit apprehensive to do so. Well – I’ve gone through the pain myself, so you don’t have to!


A bit ago, I got the opportunity to upgrade my work laptop to an M1 MacBook Pro. And yes, it has been painful. But also, it’s been worth it. I’ve gotten past most of the pain, but it took me a while to sit my lazy self down and figure out a workflow that works for me.

This isn’t a post to convince someone to upgrade, or to not upgrade. This post will not include how I setup my terminal or zshell or editor for regular development. This is me just sharing how I’ve setup my machine for development specifically for working on an M1 machine. I focus on Python development, but parts will help any type of development.

What’s Different About This Post

There are many write ups on how folks setup their Mac for development. However, very few (if any) setup their computers to make use of the M1 processor – many just use the Rosetta emulator for everything.

I’ve created a split setup, which tries to take advantage of the tools and packages that have indeed been made available for M1 machines, and use an emulated x86 environment when not.

Disclaimers

  • I focus on setting up zsh with Homebrew, pyenv, pyenv-virtualenv, and pipx. However, the approach used can be applied with bash or fish, for Ruby’s rbenv, Java’s jenv, Node’s nvm, anything that’s essentially path management.
  • I specifically avoid using conda as I’ve found conda and virtual environments do not play nicely together. I’m sure conda can be helpful for some folks, especially if only working in science-, math-, and/or research-related projects.
  • I may be missing some steps; it was months ago that I got this computer. I apologize! But I trust y’all are smart to figure out what’s missing.
  • My approach may not work for you and your workflow; or may not go far enough for you. Feel free to use the comment section to share what you’ve done differently.
  • See Miscellany for tidbits on Docker, and Tensorflow.
  • I’ll try to keep this updated as I discover new quirks.

Step 0: About This Mac

To start, here are the relevant details of the machine I’m working with, as of the date of this post:

  • MacBook Pro (14-inch, 2021)
  • OS: Monterey, 12.4
  • Chip: Apple M1 Max

About This Mac Screenshot

Step 1: Rosetta 2

Rosetta 2 is an “emulator” or a translator for software built for Intel-based processors to run on Apple’s Silicon/M1 processors.

While many apps for macOS have transitioned to running on M1 machines, there are still a lot of non-user-facing (a.k.a developer-facing) software and tools that do not play nicely. For instance, for Python, there are many packages with C-extensions whose binaries are not yet built for the M1, causing a lot of headaches (I’m looking at you, grpcio, tensorflow, librosa). Then there’s Docker, which will run fine on Apple Silicon, but can cause frustration when trying to build & deploy to a non-M1 environment. Enter: Rosetta 2.

Setup

In a terminal, run:

softwareupdate --install-rosetta --agree-to-license

Optional Step 2: iTerm2 for Rosetta and Native

This step is entirely optional. However, if you choose to skip this step, you’ll want to do Step 3.3.

You may find it a lot easier to have two copies of iTerm.app (or Terminal.app), one that runs with Rosetta, and one that does not. Having both makes it easy to visually separate which environment you’re working in, as you can now customize the look and theme of each terminal app.

Setup

  1. Open Finder to /Applications (or /Applications/Utilities if using Terminal.app).
  2. Create a copy of iTerm.app (or Terminal.app). Name the copy Rosetta-iTerm.app (or Rosetta-Terminal.app, or whatever that makes sense to you).

    Create a copy of iTerm for Rosetta

  3. Right-click on the new Rosetta terminal copy, and click “Get Info”.

  4. Check “Open using Rosetta” then close the “Get Info” window.

    Open using Rosetta

  5. Open the Rosetta-version of your terminal app and confirm it’s using Rosetta:

    $ arch
    i386
    $ uname -m
    x86_64
    
  6. Open the native version of your terminal app to see what the output of those commands look like otherwise:

    $ arch
    arm64
    $ uname -m
    arm64
    

Now we’ve created a copy of our terminal app that can be used for tools not yet available for the M1.

Additional Optional Steps

  • For a helpful visual cue about which terminal you’re running, make an adjustment to your terminal’s general theme. I just made the background of mind a little lighter.
  • When Rosetta-iTerm.app is open, the menu bar still says “iTerm2”. You can change this by opening up /Applications/Rosetta-iTerm.app/Contents/Info.plist and making the following edit (you’ll have to restart the app for it to pick up):

      <key>CFBundleName</key>
    - <string>iTerm2</string>
    + <string>Rosetta-iTerm2</string>
      <key>CFBundlePackageType</key>
      <string>APPL</string>
    

Step 3: Initial Shell Setup

Depending on whether I’m running in an emulated environment or not, the development tools I install (i.e. homebrew) and setup (i.e. pyenv, pipx) will live in different paths.

I trust that those using bash or fish can figure out how to translate this step appropriately.

Setup

  1. Create two files: ~/.zshrc.x86_64 and ~/.zshrc.arm64.
  2. Open ~/.zshrc and add the following snippet:

    # Detect if running Rosetta or not to pull in specific config
    if [ "$(sysctl -n sysctl.proc_translated)" = "1" ]; then
        # rosetta (x86_64)
        source ~/.zshrc.x86_64
    else
        # regular (arm64)
        source ~/.zshrc.arm64
    fi
    
  3. Optionally, in the same ~/.zshrc file, add the following snippet to allow you to switch between a Rosetta-based shell and native. This is quite handy, particularly if you skipped the optional step 2.

    alias rosetta='(){ arch -x86_64 $SHELL ; }'
    alias native='(){ arch -arm64e $SHELL ; }'
    

We’ll come back to add to the two new zshrc files we’ve created to configure homebrew, pyenv, and pipx.

See Appendix below for the full ~/.zshrc* files.

Optional Step

When running in Rosetta, I’ve added a prefix to my prompt. In my ~/.zshrc.x86_64 file, I defined a new function:

function rosetta {
    echo "%{$fg_bold[blue]%}(%{$FG[205]%}x86%{$fg_bold[blue]%})%{$reset_color%}"
}

Then, I added it to my prompt:

PROMPT='$(rosetta)$(virtualenv_info)$(collapse_pwd)$(prompt_char)$(git_prompt_info)'

That looks like:

Arch added to prompt

Note: in the above PROMPT declaration, virtualenv_info, collapse_pwd, and prompt_char are custom functions; git_prompt_info comes from oh-my-zsh.

Step 4: Native Installation

First, we’ll setup brew pyenv, pyenv-virtualenv, and pipx for our native environment. We’ll setup brew, pyenv, and pipx with their respective defaults, where brew installs into /opt/homebrew, pyenv with ~/.pyenv, and pipx with ~/.local.

In the next step, we will do the same within a Rosetta terminal/shell with different directories. It’s particularly helpful to have pyenv and pipx separated like this, so that each installation can’t interact with the other. That is, our Rosetta-installed pyenv-virtualenv or pipx can’t see or delete virtualenvs that were created with the native installed pyenv-virtualenv or pipx, and vice versa. I won’t accidentally activate a virtual environment in the native shell that can only work in Rosetta.

Setup

In the native (not Rosetta) shell or terminal app:

  1. Install Homebrew by running:

    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    
  2. Add the following to the new ~/.zshrc.arm64 file:

    # Brew setup for arm64
    local brew_path="/opt/homebrew/bin"
    local brew_opt_path="/opt/homebrew/opt"
    export PATH="${brew_path}:${PATH}"
    eval "$(${brew_path}/brew shellenv)"
    
  3. Start a new native shell/terminal session in order to pick up what we’ve added to ~/.zshrc.arm64.

  4. Run the following command to install the required packages for pyenv setup:

    brew install openssl readline sqlite3 xz zlib tcl-tk
    
  5. Install pyenv:

    brew install pyenv
    
  6. Add the following to ~/.zshrc.arm64:

    # setup for pyenv
    export PYENV_ROOT="$HOME/.pyenv"
    command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
    eval "$(pyenv init -)"
    
  7. Start a new native shell/terminal session again in order to pick up what we’ve added to ~/.zshrc.arm64.

  8. Install pyenv-virtualenv:

    brew install pyenv-virtualenv
    
  9. Start a new native shell/terminal session again in order to pick up what we’ve added to ~/.zshrc.arm64.

  10. Install pipx:

    brew install pipx
    
  11. Add the following to ~/.zshrc.arm64:

    # `pipx` setup
    export PATH="$PATH:/Users/lynn/.local/bin"
    export PIPX_BIN_DIR="$HOME/.local/bin"
    export PIPX_HOME="$HOME/.local/pipx"
    

Start a new native shell/terminal session again in order to pick up what we’ve added to ~/.zshrc.arm64.

Step 5: Rosetta Installation

Now, we’re going to install and setup brew, pyenv, pyenv-virtualev, and pipx for Rosetta. brew will automatically install into /usr/local, while we’ll have to configure pyenv and pipx to look into different directories when setting up Python versions and virtualenvs.

Caution! The steps below look quite similar to the previous step. Pay attention to:

  • The different paths for both brew, pyenv, and pipx; and
  • The new directories for pyenv and pipx.

Setup

In the Rosetta-enabled (not native) terminal app or shell:

  1. Install Homebrew by running:

    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    
  2. Add the following to the new ~/.zshrc.x86_64 file:

    # Brew paths for x86_64
    local brew_path="/usr/local/bin"
    local brew_opt_path="/usr/local/opt"
    export PATH="${brew_path}:${PATH}"
    eval "$(${brew_path}/brew shellenv)"
    
  3. Start a new Rosetta shell/terminal session in order to pick up what we’ve added to ~/.zshrc.x86_64.

  4. Run the following command to install the required packages for pyenv setup:

    brew install openssl readline sqlite3 xz zlib tcl-tk
    
  5. Install pyenv:

    brew install pyenv
    
  6. Create a new directory for pyenv running in Rosetta:

    mkdir -p ~/.pyenv.x86_64
    
  7. Add the following to ~/.zshrc.x86_64:

    # setup for pyenv
    export PYENV_ROOT="$HOME/.pyenv.x86_64"
    command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
    eval "$(pyenv init -)"
    
  8. Start a new Rosetta shell/terminal session in order to pick up what we’ve added to ~/.zshrc.x86_64.

  9. Install pyenv-virtualenv:

    brew install pyenv-virtualenv
    
  10. Start a new Rosetta shell/terminal session in order to pick up what we’ve added to ~/.zshrc.x86_64.

  11. Create a new directory for pyenv running in Rosetta:

    mkdir -p ~/.local/x86_64
    
  12. Add the following to ~/.zshrc.x86_64:

    # `pipx` setup
    export PATH="$PATH:/Users/lynn/.local/x86_64/bin"
    export PIPX_BIN_DIR="$HOME/.local/x86_64/bin"
    export PIPX_HOME="$HOME/.local/x86_64/pipx"
    

Start a new Rosetta shell/terminal session in order to pick up what we’ve added to ~/.zshrc.x86_64.

Miscellany

Docker

The Docker for Mac app works on Apple Silicon just fine. You may need explicitly define what platform is needed (e.g. --platform=linux/arm64 or --platform=linux/amd64) when building or pulling.

Tensorflow on Docker

Tensorflow does not have official binaries for M1 machines (a.k.a. one can’t simply pip install tensorflow), but has released a separate package, tensorflow-macos. But there is not a pre-built native solution for running Tensorflow in Docker on a native M1 environment; it must be in Rosetta (--platform=linux/amd64), or you must build Tensorflow from source.

As an anecdote, I’ve found that running a Tensorflow-based model in Docker within an emulated environment is significantly slower than running in a native environment. Very roughly, it’s about 10x slower to get a prediction in an emulated environment than native. Therefore, it may be worth it to build Tensorflow from source as a base image, then copy the built binaries into the images you’re developing on.

Warning: Building Tensorflow from source can take a couple of hours.

See the Dockerfiles in the Appendix.

Appendix

zshrc files

Caution: Be sure to follow the installation steps in Step 4 and Step 5 above in order for these ~/.zshrc* files to work.

~/.zshrc

This snips out bits of my own custom setup:

# <-- snip -->
# Rosetta or native-related zsh config
if [ "$(sysctl -n sysctl.proc_translated)" = "1" ]; then
    # rosetta (x86_64)
    source ~/.zshrc.x86_64
else
    # regular (arm64)
    source ~/.zshrc.arm64
fi
# <-- snip -->
~/.zshrc.x86_64
# `brew` setup
local brew_path="/usr/local/bin"
local brew_opt_path="/usr/local/opt"
export PATH="${brew_path}:${PATH}"
eval "$(${brew_path}/brew shellenv)"

# `pyenv` setup
export PYENV_ROOT="$HOME/.pyenv86"
command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"

# `pipx` setup
export PATH="$PATH:/Users/lynn/.local/x86_64/bin"
export PIPX_BIN_DIR="$HOME/.local/x86_64/bin"
export PIPX_HOME="$HOME/.local/x86_64/pipx"

# pretty stuff
function rosetta {
    echo "%{$fg_bold[blue]%}(%{$FG[205]%}x86%{$fg_bold[blue]%})%{$reset_color%}"
}
PROMPT='$(rosetta)$(virtualenv_info) $(collapse_pwd)$(prompt_char)$(git_prompt_info)'
~/.zshrc.arm64
# `brew` setup
local brew_path="/opt/homebrew/bin"
local brew_opt_path="/opt/homebrew/opt"
export PATH="${brew_path}:${PATH}"
eval "$(${brew_path}/brew shellenv)"

# `pyenv` setup
export PYENV_ROOT="$HOME/.pyenv"
command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"

# `pipx` setup
export PATH="$PATH:/Users/lynn/.local/bin"
export PIPX_BIN_DIR="$HOME/.local/bin"
export PIPX_HOME="$HOME/.local/pipx"

# pretty stuff
PROMPT='$(virtualenv_info)$(collapse_pwd)$(prompt_char)$(git_prompt_info)'

Dockerfile for Tensorflow

This is one single Dockerfile, but it’d probably be good to separate into two.

# This builds tensorflow v2.7.0 from source to be able to run on M1 within Docker 
# as they do not provide binaries for arm64 platforms.
# https://github.com/tensorflow/tensorflow/issues/52845
#
# Adapted from https://www.tensorflow.org/install/source
#
# Warning!! this can take 1 - 2 hours to build.
#
# If a different version of tf is needed, you'll need to figure out the min
# bazel version (see doc link above). You may also need to figure out the
# build dependency version limitations - I learned `numpy<1.18` and `numba<0.55`
# the hard way :-!.
#
# --platform isn't needed particularly if building on M1, but it's more
# for info purposes, and as a safe guard if a non-M1 arch tries to build this
# Dockerfile
FROM --platform=linux/arm64 python:3.8-buster AS tf_build

WORKDIR /usr/src/

# minimum bazel version for tf 2.7.0 to build
ENV USE_BAZEL_VERSION=3.7.2

RUN apt-get update \
    && apt-get install -y \
        # deps for building tf
        python3-dev curl gnupg \
    && rm -rf /var/lib/apt/lists/*

RUN pip install -U pip setuptools
# tensorflow build dependencies
RUN pip install -U wheel && \
    # limit numpy https://github.com/tensorflow/tensorflow/issues/40688
    # & numba to work with numpy
    pip install "numpy<1.18" "numba<0.55" && \
    pip install -U keras_preprocessing --no-deps

# install bazel via bazelisk
RUN curl -sL https://deb.nodesource.com/setup_11.x  | bash -
RUN apt-get -y install nodejs && \
    npm install -g @bazel/bazelisk

# clone tf
RUN git clone https://github.com/tensorflow/tensorflow.git /usr/src/tensorflow
WORKDIR /usr/src/tensorflow
# trying to build tf 2.7.0
RUN git checkout v2.7.0
# and let's see if we can build
RUN ./configure
# build pip package - this took me 1 - 1.5 hrs!
RUN bazel build \
    --incompatible_restrict_string_escapes=false \
    --config=noaws \
    # optionally limit ram if needed (default to host ram)
    # --local_ram_resources=3200 \
    # optionally limit cpus if needed (default to host number)
    # --local_cpu_resources=8 \
    //tensorflow/tools/pip_package:build_pip_package
# create a wheel in /tmp/tensorflow_pkg
RUN ./bazel-bin/tensorflow/tools/pip_package/build_pip_package /tmp/tensorflow_pkg

# the tf io dep is not available on PyPI for arm64, so must build this from source too
RUN git clone https://github.com/tensorflow/io.git /usr/src/io
WORKDIR /usr/src/io
RUN python setup.py bdist_wheel \
    --project tensorflow_io_gcs_filesystem \
    --dist-dir /tmp/tensorflow_io_pkg

#####
# Probably where you want to create a second Dockerfile
#####
FROM --platform=linux/arm64 python:3.8-buster
# copy tf and deps
COPY --from=tf_build /tmp/tensorflow_io_pkg/*.whl /usr/src/io/
COPY --from=tf_build /tmp/tensorflow_pkg/*.whl /usr/src/tf/
# (re)install tf build deps first before tf
RUN pip install "numpy<1.18" "numba<0.55" && \
    pip install -U keras_preprocessing --no-deps
# install tf & dependency
RUN pip install /usr/src/io/*.whl && \
    pip install /usr/src/tf/*.whl



Has this article been helpful for you? Consider expressing your gratitude!
Need some help? I'm available for tutoring, mentoring, and interview prep!


comments powered by Disqus