├── .coveragerc ├── .flake8 ├── .github └── workflows │ ├── ci.yml │ ├── example.yml │ └── nightly.yml ├── .gitignore ├── CHANGELOG.rst ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── NOTICE.txt ├── README.rst ├── deps-licenses.md ├── docs └── perf8-screencast.gif ├── fixpath.py ├── perf8 ├── __init__.py ├── cli.py ├── logger.py ├── plot.py ├── plugins │ ├── __init__.py │ ├── _asyncstats.py │ ├── _cprofile.py │ ├── _memray.py │ ├── _psutil.py │ ├── _pyspy.py │ └── base.py ├── reporter.py ├── runner.py ├── speedscope │ ├── LICENSE │ ├── README │ ├── demangle-cpp.1768f4cc.js │ ├── demangle-cpp.1768f4cc.js.map │ ├── favicon-16x16.f74b3187.png │ ├── favicon-32x32.bc503437.png │ ├── file-format-schema.json │ ├── import.a03bf119.js │ ├── import.a03bf119.js.map │ ├── index.html │ ├── perf-vertx-stacks-01-collapsed-all.2681da68.txt │ ├── release.txt │ ├── reset.8c46b7a1.css │ ├── reset.8c46b7a1.css.map │ ├── source-map.438fa06b.js │ ├── source-map.438fa06b.js.map │ ├── speedscope.eee21de6.js │ └── speedscope.eee21de6.js.map ├── statsd_server.py ├── templates │ ├── base.html │ └── index.html ├── tests │ ├── __init__.py │ ├── ademo.py │ ├── demo.py │ ├── test_cli.py │ └── test_watcher.py └── watcher.py ├── requirements.txt ├── setup.py ├── tests-requirements.txt └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = perf8/__init__.py,perf8/tests/* 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501,E203 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: 16 | - '3.10' 17 | fail-fast: false 18 | name: Test on Python ${{ matrix.python-version }} 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Setup Python 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install tox 26 | run: pip install tox tox-gh-actions 27 | - name: Install dot 28 | run: sudo apt-get install graphviz -y 29 | - name: Test with tox 30 | run: tox 31 | -------------------------------------------------------------------------------- /.github/workflows/example.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Perf report demo 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: 17 | - '3.10' 18 | fail-fast: false 19 | name: Performance report using Python ${{ matrix.python-version }} 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Setup Python 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install dot 29 | run: sudo apt-get install graphviz -y 30 | 31 | - name: Install perf8 32 | run: pip3 install perf8 33 | 34 | - name: Run Perf test 35 | run: perf8 --all 36 | 37 | - name: Setup GitHub Pages 38 | id: pages 39 | uses: actions/configure-pages@v1 40 | 41 | - name: Upload pages artifact 42 | uses: actions/upload-pages-artifact@v1 43 | with: 44 | path: /home/runner/work/perf8/perf8/perf8-report 45 | 46 | - name: Deploy to GitHub Pages 47 | id: deployment 48 | uses: actions/deploy-pages@v1 49 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Perf report 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: 19 | - '3.10' 20 | fail-fast: false 21 | name: Performance report using Python ${{ matrix.python-version }} 22 | steps: 23 | - uses: actions/checkout@v3 24 | 25 | - name: Setup Python 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Install dot 31 | run: sudo apt-get install graphviz -y 32 | 33 | - name: Install perf8 34 | run: | 35 | python3 -m venv . 36 | bin/pip install -r requirements.txt 37 | bin/pip install -e . 38 | 39 | - name: Run Perf test 40 | run: bin/perf8 --all -c perf8/tests/ademo.py 41 | 42 | - name: Setup GitHub Pages 43 | id: pages 44 | uses: actions/configure-pages@v1 45 | 46 | - name: Upload pages artifact 47 | uses: actions/upload-pages-artifact@v1 48 | with: 49 | path: /home/runner/work/perf8/perf8/perf8-report 50 | 51 | - name: Deploy to GitHub Pages 52 | id: deployment 53 | uses: actions/deploy-pages@v1 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | share 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | bin/ 114 | pyvenv.cfg 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | 135 | # vim 136 | .*.un~ 137 | .*.swp 138 | 139 | # test reports 140 | perf8-report/ 141 | 142 | # jetbrains 143 | .idea 144 | *.iml -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | 0.0.2 3 | not released 4 | 5 | 0.0.1 - 2023/01/06 6 | ================== 7 | 8 | - Initial release 9 | 10 | 0.0.0 - 2022/11/11 11 | ================== 12 | 13 | - Don't use it. 14 | 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | 3 | ENV TZ="Etc/UTC" 4 | ENV DEBIAN_FRONTEND="noninteractive" 5 | 6 | RUN apt-get update 7 | RUN apt install --yes --no-install-recommends python3-pip software-properties-common curl 8 | RUN add-apt-repository ppa:deadsnakes/ppa 9 | RUN apt remove python3 -y 10 | RUN apt install python3.10 -y 11 | RUN curl -sS https://bootstrap.pypa.io/get-pip.py | python3.10 12 | RUN python3.10 -m pip install --upgrade pip setuptools wheel 13 | 14 | 15 | COPY . /app 16 | WORKDIR /app 17 | 18 | RUN pip3 install -r requirements.txt && \ 19 | pip3 install -r tests-requirements.txt && \ 20 | pip3 install . 21 | 22 | ENV RANGE=5000 23 | 24 | CMD perf8 -v --memray --pyspy --psutil --asyncstats -t /app/perf8-report -c "/app/perf8/tests/ademo.py" 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.rst 2 | include README.rst 3 | include LICENSE 4 | include CHANGES.rst 5 | include requirements.txt 6 | include tests-requirements.txt 7 | include tox.ini 8 | include Makefile 9 | include NOTICE.txt 10 | recursive-include perf8/templates *.html 11 | recursive-include perf8/speedscope *.* 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | HERE = $(shell pwd) 2 | BIN = $(HERE)/bin 3 | PYTHON = $(BIN)/python 4 | BUILD_DIRS = bin build include lib lib64 man share 5 | VIRTUALENV = virtualenv 6 | SYSTEM_PYTHON = python3.10 7 | 8 | .PHONY: all install test build clean docs 9 | 10 | all: build 11 | 12 | $(PYTHON): 13 | $(SYSTEM_PYTHON) -m venv . 14 | $(PYTHON) -m pip install --upgrade pip 15 | $(BIN)/pip install -r requirements.txt 16 | $(BIN)/pip install tox 17 | $(BIN)/pip install twine 18 | 19 | install: $(PYTHON) 20 | bin/pip install -e . 21 | 22 | dev: install 23 | bin/pip install -r tests-requirements.txt 24 | 25 | clean: 26 | rm -rf $(BUILD_DIRS) 27 | 28 | test: $(PYTHON) 29 | $(BIN)/tox 30 | 31 | docs: $(PYTHON) 32 | $(BIN)/tox -e docs 33 | 34 | lint: $(PYTHON) 35 | $(BIN)/tox -e flake8 36 | 37 | docker-run: 38 | rm -rf perf8-report 39 | docker run -v $(PWD):/app --cap-add sys_ptrace --rm --name perf8 -it perf8 40 | $(PYTHON) fixpath.py 41 | 42 | docker-build: 43 | docker build -t perf8 . --progress plain 44 | 45 | update-deps: 46 | $(BIN)/pip install pip-licenses 47 | $(BIN)/pip-licenses --format=markdown > deps-licenses.md 48 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | perf8 2 | 3 | Copyright 2022 Elasticsearch B.V. 4 | 5 | --- 6 | 7 | This product contains a vendored copy of the speedscope project in `perf8/speedscope` so we can display speedscope reports directly via the `perf8` report. 8 | 9 | MIT License 10 | 11 | Copyright (c) 2018 Jamie Wong 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining a copy 14 | of this software and associated documentation files (the "Software"), to deal 15 | in the Software without restriction, including without limitation the rights 16 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 17 | copies of the Software, and to permit persons to whom the Software is 18 | furnished to do so, subject to the following conditions: 19 | 20 | The above copyright notice and this permission notice shall be included in all 21 | copies or substantial portions of the Software. 22 | 23 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 24 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 25 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 26 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 27 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 28 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 29 | SOFTWARE. 30 | 31 | --- 32 | 33 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | perf8 2 | ===== 3 | 4 | .. image:: https://github.com/tarekziade/perf8/actions/workflows/ci.yml/badge.svg?branch=main 5 | :target: https://github.com/tarekziade/perf8/actions/workflows/ci.yml?query=branch%3Amain 6 | 7 | 8 | **THIS IS ALPHA QUALITY, UNSUPPORTED, RUN AT YOUR OWN RISKS** 9 | 10 | Your Tool For Python Performance Tracking 11 | 12 | `perf8` is a curated list of tools to track the performance of your Python app. 13 | 14 | The project is pluggable, and ships with a few tools: 15 | 16 | - cprofile - a cProfile to Dot graph generator 17 | - pyspy - a py-spy speedscope generator 18 | - memray - a memory flamegraph generator 19 | - psutil - a psutil integration 20 | - asyncstats - stats on the asyncio eventloop usage (for async apps) 21 | 22 | Installation 23 | ------------ 24 | 25 | Use `pip`: 26 | 27 | .. code-block:: sh 28 | 29 | pip install perf8 30 | 31 | If you use the `cprofile` plugin, you will need to install `Graphviz` to 32 | get the `dot` utility. See. https://graphviz.org/download/ 33 | 34 | 35 | 36 | Usage 37 | ----- 38 | 39 | Running the `perf8` command against your Python module: 40 | 41 | .. code-block:: sh 42 | 43 | perf8 --all -c /my/python/script.py --option1 44 | 45 | Will generate a self-contained HTML report, making it suitable for 46 | running it in automation and produce performance artifacts. 47 | 48 | You can pick specific plugins. Run `perf --help` and use the ones you want. 49 | 50 | 51 | Async applications 52 | ------------------ 53 | 54 | Running the `asyncstats` plugin requires to provide your application event loop. 55 | 56 | In order to do this, you need to instrument your application to give `perf8` 57 | the loop to watch. You can use the `enable` and `disable` coroutines: 58 | 59 | .. code-block:: python 60 | 61 | import perf8 62 | 63 | async def my_app(): 64 | await perf8.enable(my_loop) 65 | try: 66 | # my code 67 | await run_app() 68 | finally: 69 | await perf8.disable() 70 | 71 | 72 | To avoid running this code in production you can use the `PERF8` environment variable 73 | to detect if `perf8` is calling your app: 74 | 75 | 76 | .. code-block:: python 77 | 78 | import os 79 | 80 | if 'PERF8' in os.environ: 81 | import perf8 82 | 83 | async with perf8.measure(): 84 | await run_app() 85 | else: 86 | await run_app() 87 | 88 | 89 | Running and publishing using Github 90 | ----------------------------------- 91 | 92 | You can use Github Actions and Github Pages to run and publish a performance report. In your project, 93 | add a `.github/wokflows/perf.yml` file with this content: 94 | 95 | .. code-block:: yaml 96 | 97 | --- 98 | name: Perf report demo 99 | on: 100 | workflow_dispatch: 101 | 102 | permissions: 103 | contents: read 104 | pages: write 105 | id-token: write 106 | 107 | jobs: 108 | build: 109 | runs-on: ubuntu-latest 110 | strategy: 111 | matrix: 112 | python-version: 113 | - '3.10' 114 | fail-fast: false 115 | name: Performance report using Python ${{ matrix.python-version }} 116 | steps: 117 | - uses: actions/checkout@v3 118 | 119 | - name: Setup Python 120 | uses: actions/setup-python@v4 121 | with: 122 | python-version: ${{ matrix.python-version }} 123 | 124 | - name: Install dot 125 | run: sudo apt-get install graphviz -y 126 | 127 | - name: Install perf8 128 | run: pip3 install perf8 129 | 130 | - name: Run Perf test 131 | run: perf8 --all 132 | 133 | - name: Setup GitHub Pages 134 | id: pages 135 | uses: actions/configure-pages@v1 136 | 137 | - name: Upload pages artifact 138 | uses: actions/upload-pages-artifact@v1 139 | with: 140 | path: /home/runner/work/perf8/perf8/perf8-report 141 | 142 | - name: Deploy to GitHub Pages 143 | id: deployment 144 | uses: actions/deploy-pages@v1 145 | 146 | 147 | And extend `perf8 --all` so it runs your app. You will also need to go in the settings of your project and 148 | the `Pages` page to switch to `GitHub Actions` for the `Build And Deployement` source. 149 | 150 | When the action runs, it will update `https://.github.io/` 151 | 152 | 153 | 154 | Screencast 155 | ---------- 156 | 157 | .. image:: docs/perf8-screencast.gif 158 | -------------------------------------------------------------------------------- /deps-licenses.md: -------------------------------------------------------------------------------- 1 | | Name | Version | License | 2 | |--------------------|-----------|---------------------------------------------------------| 3 | | Jinja2 | 3.1.2 | BSD License | 4 | | MarkupSafe | 2.1.1 | BSD License | 5 | | Pillow | 9.3.0 | Historical Permission Notice and Disclaimer (HPND) | 6 | | Pygments | 2.13.0 | BSD License | 7 | | commonmark | 0.9.1 | BSD License | 8 | | contourpy | 1.0.6 | BSD License | 9 | | cycler | 0.11.0 | BSD License | 10 | | flameprof | 0.4 | MIT License | 11 | | fonttools | 4.38.0 | MIT License | 12 | | gprof2dot | 2022.7.29 | GNU Lesser General Public License v3 or later (LGPLv3+) | 13 | | humanize | 4.4.0 | MIT License | 14 | | importlib-metadata | 5.1.0 | Apache Software License | 15 | | kiwisolver | 1.4.4 | BSD License | 16 | | matplotlib | 3.6.2 | Python Software Foundation License | 17 | | memray | 1.5.0 | Apache Software License | 18 | | numpy | 1.23.5 | BSD License | 19 | | packaging | 22.0 | Apache Software License; BSD License | 20 | | perf8 | 0.0.0 | Apache Software License | 21 | | psutil | 5.9.4 | BSD License | 22 | | py-spy | 0.3.14 | MIT License | 23 | | pyparsing | 3.0.9 | MIT License | 24 | | python-dateutil | 2.8.2 | Apache Software License; BSD License | 25 | | rich | 12.6.0 | MIT License | 26 | | six | 1.16.0 | MIT License | 27 | | zipp | 3.11.0 | MIT License | 28 | -------------------------------------------------------------------------------- /docs/perf8-screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/perf8/ac27d20d5779c7060f1b514ff25410a579a05be1/docs/perf8-screencast.gif -------------------------------------------------------------------------------- /fixpath.py: -------------------------------------------------------------------------------- 1 | # trick to replace the abs path in speedscope 2 | # when it's ran used Docker 3 | import os 4 | 5 | HERE = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | with open('perf8-report/pyspy.html') as f: 8 | data = f.read() 9 | 10 | data = data.replace('/app/perf8-report/results.js', f'{HERE}/perf8-report/results.js') 11 | 12 | with open('perf8-report/pyspy.html', 'w') as f: 13 | f.write(data) 14 | -------------------------------------------------------------------------------- /perf8/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | __version__ = "0.0.2" 20 | 21 | 22 | try: 23 | from perf8.plugins.base import enable, disable, measure # NOQA 24 | except Exception: 25 | pass 26 | -------------------------------------------------------------------------------- /perf8/cli.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import sys 20 | import asyncio 21 | import argparse 22 | import os 23 | import logging 24 | 25 | from perf8 import __version__ 26 | from perf8.plugins.base import get_registered_plugins 27 | from perf8.watcher import WatchedProcess 28 | from perf8.logger import set_logger, logger 29 | 30 | 31 | HERE = os.path.dirname(__file__) 32 | 33 | 34 | def parser(): 35 | aparser = argparse.ArgumentParser( 36 | description="Python Performance Tracking.", 37 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 38 | ) 39 | 40 | for plugin in get_registered_plugins(): 41 | aparser.add_argument( 42 | f"--{plugin.name}", 43 | action="store_true", 44 | default=False, 45 | help=plugin.description, 46 | ) 47 | 48 | for name, options in plugin.arguments: 49 | aparser.add_argument(f"--{plugin.name}-{name}", **options) 50 | 51 | aparser.add_argument( 52 | "-t", 53 | "--target-dir", 54 | default=os.path.join(os.getcwd(), "perf8-report"), 55 | type=str, 56 | help="target dir for results", 57 | ) 58 | 59 | aparser.add_argument( 60 | "-d", 61 | "--description", 62 | default=None, 63 | type=str, 64 | help="Describe the test in a few sentence (can be a file)", 65 | ) 66 | 67 | aparser.add_argument( 68 | "-s", 69 | "--status-filename", 70 | default="status", 71 | type=str, 72 | help="target dir for results", 73 | ) 74 | aparser.add_argument( 75 | "--title", 76 | default="Performance Report", 77 | type=str, 78 | help="String used for the report title", 79 | ) 80 | aparser.add_argument( 81 | "-r", 82 | "--report", 83 | default="report.html", 84 | type=str, 85 | help="report file", 86 | ) 87 | aparser.add_argument( 88 | "--max-duration", 89 | type=float, 90 | default=0, 91 | help="Max duration in seconds", 92 | ) 93 | aparser.add_argument( 94 | "--refresh-rate", 95 | type=float, 96 | default=5.0, 97 | help="Refresh rate", 98 | ) 99 | aparser.add_argument( 100 | "-c", 101 | "--command", 102 | default=os.path.join(HERE, "tests", "demo.py"), 103 | type=str, 104 | nargs=argparse.REMAINDER, 105 | help="Command to run", 106 | ) 107 | 108 | aparser.add_argument( 109 | "--statsd", 110 | action="store_true", 111 | default=False, 112 | help="Starts a Statsd server", 113 | ) 114 | 115 | aparser.add_argument( 116 | "--statsd-port", 117 | type=int, 118 | default=514, 119 | help="Statsd port", 120 | ) 121 | 122 | aparser.add_argument( 123 | "--all", 124 | action="store_true", 125 | default=False, 126 | help="Use all plugins", 127 | ) 128 | 129 | aparser.add_argument( 130 | "--version", 131 | action="store_true", 132 | default=False, 133 | help="Displays version and exits.", 134 | ) 135 | 136 | aparser.add_argument( 137 | "-v", 138 | "--verbose", 139 | action="count", 140 | default=0, 141 | help=( 142 | "Verbosity level. -v will display " 143 | "tracebacks. -vv requests and responses." 144 | ), 145 | ) 146 | 147 | return aparser 148 | 149 | 150 | def main(args=None): 151 | os.environ["PERF8_ARGS"] = "::".join(sys.argv[1:]) 152 | 153 | if args is None: 154 | aparser = parser() 155 | args = aparser.parse_args() 156 | 157 | if args.version: 158 | print(__version__) 159 | sys.exit(0) 160 | 161 | if args.all: 162 | for plugin in get_registered_plugins(): 163 | if plugin.name == "cprofile": 164 | args.cprofile = False 165 | else: 166 | setattr(args, plugin.name.replace("-", "_"), True) 167 | 168 | if args.memray and args.cprofile: 169 | raise Exception("You can't use --memray and --cprofile at the same time") 170 | 171 | if args.verbose > 0: 172 | set_logger(logging.DEBUG) 173 | else: 174 | set_logger(logging.INFO) 175 | 176 | result = asyncio.run(WatchedProcess(args).run()) 177 | 178 | status_file = os.path.join(args.target_dir, args.status_filename) 179 | logger.info(f"Writing status in {status_file}") 180 | with open(status_file, "w") as f: 181 | if result: 182 | f.write("0") 183 | else: 184 | f.write("1") 185 | 186 | return result 187 | 188 | 189 | if __name__ == "__main__": 190 | main() 191 | -------------------------------------------------------------------------------- /perf8/logger.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | """ 20 | Logger -- sets the logging and provides a `logger` global object. 21 | """ 22 | from datetime import datetime 23 | import logging 24 | from perf8 import __version__ 25 | 26 | logger = None 27 | 28 | 29 | def _formatter(prefix): 30 | return logging.Formatter( 31 | fmt="[" + prefix + "][%(asctime)s][%(levelname)s] %(message)s", 32 | datefmt="%H:%M:%S", 33 | ) 34 | 35 | 36 | class ExtraLogger(logging.Logger): 37 | def _log(self, level, msg, args, exc_info=None, extra=None): 38 | if extra is None: 39 | extra = {} 40 | extra.update( 41 | { 42 | "service.type": "perf8", 43 | "service.version": __version__, 44 | "labels.index_date": datetime.now().strftime("%Y.%m.%d"), 45 | } 46 | ) 47 | super(ExtraLogger, self)._log(level, msg, args, exc_info, extra) 48 | 49 | 50 | def set_logger(log_level=logging.INFO): 51 | global logger 52 | formatter = _formatter("perf8") 53 | 54 | if logger is None: 55 | logging.setLoggerClass(ExtraLogger) 56 | logger = logging.getLogger("perf8") 57 | handler = logging.StreamHandler() 58 | logger.addHandler(handler) 59 | 60 | logger.propagate = False 61 | logger.setLevel(log_level) 62 | logger.handlers[0].setLevel(log_level) 63 | logger.handlers[0].setFormatter(formatter) 64 | return logger 65 | 66 | 67 | set_logger() 68 | -------------------------------------------------------------------------------- /perf8/plot.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import csv 20 | import os 21 | import matplotlib.pyplot as plt 22 | import matplotlib.ticker as tkr 23 | 24 | plt.figure(figsize=(14, 10)) 25 | 26 | 27 | class Line: 28 | def __init__(self, samples_or_extractor, title, threshold, color): 29 | if callable(samples_or_extractor): 30 | self.extractor = samples_or_extractor 31 | self.samples = None 32 | else: 33 | self.samples = samples_or_extractor 34 | self.extractor = None 35 | self.title = title 36 | self.threshold = threshold 37 | self.color = color 38 | 39 | 40 | class Graph: 41 | def __init__(self, title, target_dir, target_file, ylabel, yformatter, *lines): 42 | self.lines = lines 43 | self.target_file = target_file 44 | self.target_dir = target_dir 45 | self.plot_file = os.path.join(target_dir, target_file) 46 | self.ylabel = ylabel 47 | self.title = title 48 | self.yformatter = yformatter 49 | 50 | def annotate_max(self, x_max, y_max, ax, yaxis): 51 | if self.yformatter: 52 | formatter = self.yformatter 53 | else: 54 | formatter = tkr.StrMethodFormatter("{x:,g}") 55 | formatter.set_axis(yaxis) 56 | text = formatter(y_max) 57 | ax.annotate( 58 | text, 59 | xy=(x_max, y_max), 60 | xycoords="data", 61 | xytext=(0, 0), 62 | textcoords="offset points", 63 | ha="center", 64 | va="center", 65 | arrowprops=dict(arrowstyle="->", color="black"), 66 | bbox=dict(boxstyle="square,pad=0.3", fc="w", ec="red"), 67 | ) 68 | 69 | def generate(self, plugin, path_or_rows=None): 70 | if isinstance(path_or_rows, str): 71 | with open(path_or_rows) as cvsfile: 72 | samples = [line for line in csv.reader(cvsfile, delimiter=",")] 73 | else: 74 | samples = path_or_rows 75 | 76 | plt.clf() 77 | 78 | ax = plt.gca() 79 | 80 | for line in self.lines: 81 | x = [] 82 | y = [] 83 | y_max = x_max = 0 84 | 85 | if samples is None: 86 | line_samples = line.samples 87 | extract = False 88 | else: 89 | extract = True 90 | line_samples = samples 91 | 92 | for i, row in enumerate(line_samples): 93 | if extract and i == 0: 94 | continue 95 | 96 | if extract: 97 | value = line.extractor(row) 98 | x_value = row[-1] 99 | else: 100 | x_value, value = row 101 | 102 | if value > y_max: 103 | y_max = value 104 | x_max = x_value 105 | 106 | x.append(x_value) 107 | y.append(value) 108 | 109 | plt.plot( 110 | x, y, color=line.color, linestyle="dashed", marker="o", label=line.title 111 | ) 112 | if x: 113 | xtick_step = int(len(x) / 10) if len(x) > 9 else 1 114 | existing_ticks = ax.get_xticks() 115 | ticks = list(existing_ticks[::xtick_step]) 116 | # If last tick is missing, add it! 117 | if ticks[-1] != x[-1]: 118 | ticks.append(existing_ticks[-1]) 119 | ax.set_xticks(ticks) 120 | 121 | if line.threshold is not None and line.threshold < y_max: 122 | plt.axhline(line.threshold, color="r") 123 | 124 | plt.plot(x_max, y_max, "ro") 125 | self.annotate_max(x_max, y_max, ax, ax.yaxis) 126 | 127 | plt.legend(loc=3) 128 | plt.xticks(rotation=25) 129 | plt.xlabel("Duration (s)") 130 | plt.ylabel(self.ylabel) 131 | ax = plt.gca() 132 | 133 | if self.yformatter: 134 | ax.yaxis.set_major_formatter(self.yformatter) 135 | else: 136 | ax.yaxis.set_major_formatter(tkr.StrMethodFormatter("{x:,g}")) 137 | 138 | plt.title(self.title, fontsize=20) 139 | plt.grid() 140 | plugin.info(f"Saved plot file at {self.plot_file}") 141 | plt.savefig(self.plot_file) 142 | return self.plot_file 143 | -------------------------------------------------------------------------------- /perf8/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | # loads plugins 20 | from perf8.plugins import _psutil, _cprofile, _memray, _pyspy, _asyncstats # NOQA 21 | -------------------------------------------------------------------------------- /perf8/plugins/_asyncstats.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import os 20 | import time 21 | import asyncio 22 | 23 | from perf8.plugins.base import AsyncBasePlugin, register_plugin 24 | from perf8.plot import Graph, Line 25 | from perf8.reporter import Datafile 26 | 27 | 28 | class EventLoopMonitoring(AsyncBasePlugin): 29 | name = "asyncstats" 30 | in_process = True 31 | description = "Stats on the event loop" 32 | 33 | def __init__(self, args): 34 | super().__init__(args) 35 | self.loop = self._prober = self.started_at = None 36 | self._idle_time = 5 37 | self._running = False 38 | self.proc_info = None 39 | self.report_file = os.path.join(args.target_dir, "loop.csv") 40 | self.rows = ( 41 | "lag", 42 | "num_tasks", 43 | "when", 44 | "since", 45 | ) 46 | self.data_file = Datafile(self.report_file, self.rows) 47 | 48 | async def _probe(self): 49 | while self._running: 50 | loop_time = self.loop.time() 51 | await asyncio.sleep(self._idle_time) 52 | lag = self.loop.time() - loop_time - self._idle_time 53 | when = time.time() 54 | metrics = ( 55 | lag, 56 | len(asyncio.all_tasks(self.loop)), 57 | when, 58 | int(when - self.started_at), 59 | ) 60 | try: 61 | self.data_file.add(metrics) 62 | except ValueError: 63 | self.warning(f"Failed to write in {self.report_file}") 64 | 65 | async def _enable(self, loop): 66 | self.loop = loop 67 | self.data_file.open() 68 | self.started_at = time.time() 69 | self._running = True 70 | self._prober = asyncio.create_task(self._probe()) 71 | 72 | async def _disable(self): 73 | self._running = False 74 | if self._prober is not None: 75 | await asyncio.sleep(0) 76 | await self._prober 77 | 78 | def report(self): 79 | if self.data_file.count == 0: 80 | return [] 81 | 82 | self.data_file.close() 83 | 84 | def extract_lag(row): 85 | return float(row[0]) 86 | 87 | def extract_tasks(row): 88 | return int(row[1]) 89 | 90 | graphs = [ 91 | Graph( 92 | "Event loop lag", 93 | self.target_dir, 94 | "loop_lag.png", 95 | "Seconds", 96 | None, 97 | Line(extract_lag, "Event loop lag", None, "g"), 98 | ), 99 | Graph( 100 | "Tasks concurrency", 101 | self.target_dir, 102 | "loop_coro.png", 103 | "Tasks", 104 | None, 105 | Line(extract_tasks, "Tasks concurrency", None, "g"), 106 | ), 107 | ] 108 | 109 | self.generate_plots(self.report_file, *graphs) 110 | return [ 111 | {"label": graph.title, "file": graph.plot_file, "type": "image"} 112 | for graph in graphs 113 | ] + [ 114 | { 115 | "label": "Event loop CSV data", 116 | "file": self.report_file, 117 | "type": "artifact", 118 | }, 119 | ] 120 | 121 | 122 | register_plugin(EventLoopMonitoring) 123 | -------------------------------------------------------------------------------- /perf8/plugins/_cprofile.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import shutil 20 | import pstats 21 | from subprocess import check_call 22 | import sys 23 | import os 24 | 25 | try: 26 | from cProfile import Profile 27 | except ImportError: 28 | from Profile import Profile 29 | 30 | from perf8.plugins.base import BasePlugin, register_plugin 31 | 32 | 33 | GPROF2DOT = "gprof2dot" 34 | 35 | 36 | class Profiler(BasePlugin): 37 | name = "cprofile" 38 | in_process = True 39 | description = "Runs cProfile and generate a dot graph with gprof2dot" 40 | priority = 0 41 | supported = True 42 | 43 | def __init__(self, args): 44 | super().__init__(args) 45 | self.outfile = os.path.join(self.target_dir, "profile.data") 46 | self.dotfile = os.path.join(self.target_dir, "profile.dot") 47 | self.pngfile = os.path.join(self.target_dir, "profile.png") 48 | self.profiler = Profile() 49 | location = os.path.join(os.path.dirname(sys.executable), GPROF2DOT) 50 | if os.path.exists(location): 51 | self.gprof2dot = location 52 | else: 53 | self.gprof2dot = shutil.which(GPROF2DOT) 54 | 55 | def get_profiler(self, *args, **kw): 56 | return self.profiler 57 | 58 | def _enable(self): 59 | self.profiler.enable() 60 | 61 | def _disable(self): 62 | self.profiler.disable() 63 | 64 | def report(self): 65 | # create a pstats file 66 | self.profiler.create_stats() 67 | self.profiler.dump_stats(self.outfile) 68 | stats = pstats.Stats(self.outfile) 69 | stats = stats.strip_dirs() 70 | stats.dump_stats(self.outfile) 71 | 72 | # create a dot file from the pstats file 73 | check_call( 74 | [ 75 | sys.executable, 76 | self.gprof2dot, 77 | "-f", 78 | "pstats", 79 | self.outfile, 80 | "-o", 81 | self.dotfile, 82 | ] 83 | ) 84 | 85 | # render the dot file into a png 86 | check_call(["dot", "-o", self.pngfile, "-Tpng", self.dotfile]) 87 | 88 | return [ 89 | {"label": "cProfile dot file", "file": self.dotfile, "type": "artifact"}, 90 | {"label": "cProfile png output", "file": self.pngfile, "type": "image"}, 91 | {"label": "cProfile pstats file", "file": self.outfile, "type": "artifact"}, 92 | ] 93 | 94 | 95 | register_plugin(Profiler) 96 | -------------------------------------------------------------------------------- /perf8/plugins/_memray.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | from importlib_metadata import distribution 20 | from memray import Tracker, FileDestination # , FileReader 21 | import os 22 | import sys 23 | from copy import copy 24 | 25 | from perf8.plugins.base import BasePlugin, register_plugin 26 | 27 | 28 | def load_entry_point(spec, group, name): 29 | dist_name, _, _ = spec.partition("==") 30 | matches = ( 31 | entry_point 32 | for entry_point in distribution(dist_name).entry_points 33 | if entry_point.group == group and entry_point.name == name 34 | ) 35 | return next(matches).load() 36 | 37 | 38 | class MemoryProfiler(BasePlugin): 39 | name = "memray" 40 | in_process = True 41 | description = "Runs memray and generates a flamegraph" 42 | 43 | def __init__(self, args): 44 | super().__init__(args) 45 | self.outfile = os.path.join(args.target_dir, "memreport") 46 | self.destination = FileDestination( 47 | path=self.outfile, overwrite=True, compress_on_exit=True 48 | ) 49 | if os.path.exists(self.outfile): 50 | os.remove(self.outfile) 51 | 52 | self.report_path = os.path.join( 53 | args.target_dir, "memray-flamegraph-report.html" 54 | ) 55 | if os.path.exists(self.report_path): 56 | os.remove(self.report_path) 57 | self.tracker = Tracker( 58 | destination=self.destination, 59 | # native_traces=True, 60 | follow_fork=True, 61 | trace_python_allocators=True, 62 | ) 63 | 64 | def _enable(self): 65 | self.tracker.__enter__() 66 | 67 | def _disable(self): 68 | self.tracker.__exit__(None, None, None) 69 | 70 | def report(self): 71 | old_args = copy(sys.argv) 72 | sys.argv[:] = ["memray", "flamegraph", self.outfile, "-o", self.report_path] 73 | try: 74 | load_entry_point("memray", "console_scripts", "memray")() 75 | except SystemExit: 76 | pass 77 | 78 | sys.argv[:] = old_args 79 | return [ 80 | {"label": "Memory Flamegraph", "file": self.report_path, "type": "html"}, 81 | {"label": "memray dump", "file": self.outfile, "type": "artifact"}, 82 | ] 83 | 84 | 85 | register_plugin(MemoryProfiler) 86 | -------------------------------------------------------------------------------- /perf8/plugins/_psutil.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import time 20 | import os 21 | import psutil 22 | import humanize 23 | import tempfile 24 | 25 | import matplotlib.ticker as tkr 26 | 27 | from perf8.plugins.base import BasePlugin, register_plugin 28 | from perf8.plot import Graph, Line 29 | from perf8.reporter import Datafile 30 | 31 | 32 | def scantree(path): 33 | try: 34 | for entry in os.scandir(path): 35 | if entry.is_dir(follow_symlinks=False): 36 | yield from scantree(entry.path) 37 | else: 38 | if not entry.name.startswith("."): 39 | yield entry 40 | except (FileNotFoundError, PermissionError): 41 | pass 42 | 43 | 44 | # XXX expensive 45 | def disk_usage(path): 46 | size = 0 47 | for entry in scantree(path): 48 | try: 49 | size += entry.stat().st_size 50 | except (OSError, PermissionError, FileNotFoundError): 51 | pass 52 | return size 53 | 54 | 55 | def to_rss_bytes(data): 56 | data = str(data) 57 | if data.endswith("G"): 58 | return int(data[:-1]) * 1024 * 1024 * 1024 59 | elif data.endswith("M"): 60 | return int(data[:-1]) * 1024 * 1024 61 | elif data.endswith("K"): 62 | return int(data[:-1]) * 1024 63 | else: 64 | return int(data) 65 | 66 | 67 | class ResourceWatcher(BasePlugin): 68 | name = "psutil" 69 | in_process = False 70 | description = "System metrics with psutil" 71 | priority = 0 72 | arguments = [ 73 | ("max-rss", {"type": str, "default": "0", "help": "Maximum allowed RSS"}), 74 | ( 75 | "disk-path", 76 | {"type": str, "default": tempfile.gettempdir(), "help": "Path to watch"}, 77 | ), 78 | ] 79 | 80 | def __init__(self, args): 81 | super().__init__(args) 82 | self.max_allowed_rss = to_rss_bytes(args.psutil_max_rss) 83 | self.proc_info = None 84 | self.path = args.psutil_disk_path 85 | self.target_dir = args.target_dir 86 | self.data_file = None 87 | self.report_file = os.path.join(self.args.target_dir, "report.csv") 88 | 89 | def _start(self, pid): 90 | self.proc_info = psutil.Process(pid) 91 | self.started_at = time.time() 92 | self.initial_disk_usage = disk_usage(self.path) 93 | self.initial_disk_io = psutil.disk_io_counters() 94 | 95 | self.rows = ( 96 | "disk_usage", 97 | "disk_io_read_count", 98 | "disk_io_write_count", 99 | "disk_io_read_bytes", 100 | "disk_io_write_bytes", 101 | "rss", 102 | "num_fds", 103 | "num_threads", 104 | "ctx_switch", 105 | "cpu_user", 106 | "cpu_system", 107 | "cpu_percent", 108 | "when", 109 | "since", 110 | ) 111 | self.data_file = Datafile(self.report_file, self.rows) 112 | self.data_file.open() 113 | self.max_rss = 0 114 | 115 | async def probe(self, pid): 116 | try: 117 | info = self.proc_info.as_dict() 118 | except Exception as e: 119 | self.warning(f"Could not get info {e}") 120 | return 121 | 122 | self.debug("Probing") 123 | probed_at = time.time() 124 | if info.get("memory_info") is None: 125 | self.warning("Proc info is empty") 126 | return 127 | current_rss = info["memory_info"].rss 128 | if current_rss > self.max_rss: 129 | self.max_rss = current_rss 130 | 131 | disk_io = psutil.disk_io_counters() 132 | 133 | metrics = ( 134 | disk_usage(self.path) - self.initial_disk_usage, 135 | disk_io.read_count - self.initial_disk_io.read_count, 136 | disk_io.write_count - self.initial_disk_io.write_count, 137 | disk_io.read_bytes - self.initial_disk_io.read_bytes, 138 | disk_io.write_bytes - self.initial_disk_io.write_bytes, 139 | current_rss, 140 | info["num_fds"], 141 | info["num_threads"], 142 | info["num_ctx_switches"].voluntary, 143 | info["cpu_times"].user, 144 | info["cpu_times"].system, 145 | info["cpu_percent"], 146 | probed_at, 147 | int(probed_at - self.started_at), 148 | ) 149 | 150 | try: 151 | self.data_file.add(metrics) 152 | except ValueError: 153 | self.warning(f"Failed to write in {self.report_file}") 154 | 155 | def success(self): 156 | if self.max_allowed_rss == 0: 157 | return super().success() 158 | res = self.max_rss <= self.max_allowed_rss 159 | if not res: 160 | msg = f"Max allowed RSS reached {humanize.naturalsize(self.max_rss)}" 161 | else: 162 | msg = "Excellent job, you did not kill the resources!" 163 | return res, msg 164 | 165 | def _stop(self, pid): 166 | if self.data_file is not None: 167 | if self.data_file.count == 0: 168 | self.warning("No data collected for psutil") 169 | return [] 170 | else: 171 | self.data_file.close() 172 | 173 | if self.max_allowed_rss == 0: 174 | threshold = None 175 | else: 176 | threshold = self.max_allowed_rss 177 | 178 | graphs = [ 179 | Graph( 180 | "Memory Usage", 181 | self.target_dir, 182 | "rss.png", 183 | "Bytes", 184 | tkr.FuncFormatter(humanize.naturalsize), 185 | Line( 186 | lambda row: float(row[5]), 187 | "RSS", 188 | threshold, 189 | "g", 190 | ), 191 | ), 192 | Graph( 193 | "CPU", 194 | self.target_dir, 195 | "cpu.png", 196 | "%", 197 | tkr.PercentFormatter(), 198 | Line(lambda row: float(row[11]), "CPU%", None, "g"), 199 | ), 200 | Graph( 201 | "Threads", 202 | self.target_dir, 203 | "threads.png", 204 | "Count", 205 | None, 206 | Line( 207 | lambda row: int(row[8]) / 100000000, 208 | "context switch (10k)", 209 | None, 210 | "b", 211 | ), 212 | Line(lambda row: int(row[7]), "threads", None, "g"), 213 | ), 214 | Graph( 215 | "Disk I/O", 216 | self.target_dir, 217 | "disk_io.png", 218 | "I/O Count", 219 | None, 220 | Line( 221 | lambda row: float(row[1]) / 100000, 222 | "Read Count (10M)", 223 | None, 224 | "r", 225 | ), 226 | Line( 227 | lambda row: float(row[2]) / 100000, 228 | "Write Count (10M)", 229 | None, 230 | "b", 231 | ), 232 | Line(lambda row: int(row[6]), "File Descriptors", None, "g"), 233 | ), 234 | Graph( 235 | "Disk Usage", 236 | self.target_dir, 237 | "disk.png", 238 | "Disk Usage", 239 | tkr.FuncFormatter(humanize.naturalsize), 240 | Line( 241 | lambda row: float(row[0]), 242 | "Usage", 243 | None, 244 | "g", 245 | ), 246 | Line( 247 | lambda row: float(row[3]), 248 | "Reads", 249 | None, 250 | "c", 251 | ), 252 | Line( 253 | lambda row: float(row[4]), 254 | "Writes", 255 | None, 256 | "y", 257 | ), 258 | ), 259 | ] 260 | 261 | self.generate_plots(self.report_file, *graphs) 262 | 263 | return [ 264 | {"label": graph.title, "file": graph.plot_file, "type": "image"} 265 | for graph in graphs 266 | ] + [{"label": "psutil CSV data", "file": self.report_file, "type": "artifact"}] 267 | 268 | 269 | register_plugin(ResourceWatcher) 270 | -------------------------------------------------------------------------------- /perf8/plugins/_pyspy.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import os 20 | import sys 21 | import subprocess 22 | import shutil 23 | from sys import platform 24 | import base64 25 | import time 26 | 27 | from perf8.plugins.base import BasePlugin, register_plugin 28 | 29 | 30 | PYSPY = "py-spy" 31 | SPEEDSCOPE_APP = os.path.join(os.path.dirname(__file__), "..", "speedscope") 32 | 33 | 34 | class PySpy(BasePlugin): 35 | name = "pyspy" 36 | fqn = f"{__module__}:{__qualname__}" 37 | in_process = False 38 | description = "Sampling profiler for Python" 39 | priority = 0 40 | supported = platform in ("linux", "linux2") 41 | 42 | def __init__(self, args): 43 | super().__init__(args) 44 | location = os.path.join(os.path.dirname(sys.executable), PYSPY) 45 | if os.path.exists(location): 46 | self.pyspy = location 47 | else: 48 | self.pyspy = shutil.which(PYSPY) 49 | if self.pyspy is None: 50 | raise Exception("Cannot find py-spy") 51 | 52 | # could be in the plugin metadata 53 | if not self.supported: 54 | self.info(f"pyspy support on {platform} is not great") 55 | self.profile_file = os.path.join(self.target_dir, "speedscope.json") 56 | self.proc = None 57 | 58 | def check_pid(self, pid): 59 | try: 60 | os.kill(pid, 0) 61 | except OSError: 62 | return False 63 | return True 64 | 65 | def _start(self, pid): 66 | running = self.check_pid(pid) 67 | start = time.time() 68 | while not running and time.time() - start < 120: 69 | self.warning(f"Process {pid} not running...") 70 | time.sleep(1.0) 71 | 72 | if not running: 73 | raise OSError(f"Process {pid} not running...") 74 | 75 | command = [ 76 | self.pyspy, 77 | "record", 78 | "--nonblocking", 79 | "-s", 80 | "--format", 81 | "speedscope", 82 | "-o", 83 | self.profile_file, 84 | "--pid", 85 | str(pid), 86 | ] 87 | self.debug(f"Running {command}") 88 | 89 | self.proc = subprocess.Popen(command) 90 | while self.proc.pid is None: 91 | time.sleep(0.1) 92 | 93 | code = self.proc.poll() 94 | if code is not None: 95 | self.warning(f"pyspy exited immediately with code {code}") 96 | 97 | def _stop(self, pid): 98 | self.debug("Pyspy should stop by itself...") 99 | running = self.check_pid(pid) 100 | start = time.time() 101 | while running and time.time() - start < 120: 102 | self.warning(f"Process {pid} still running. Waiting...") 103 | time.sleep(1.0) 104 | running = self.check_pid(pid) 105 | 106 | if self.proc.poll() is None: 107 | self.debug("Stopping Py-spy") 108 | self.proc.terminate() 109 | try: 110 | returncode = self.proc.wait(timeout=120) 111 | self.debug(f"return code is {returncode}") 112 | except subprocess.TimeoutExpired: 113 | self.proc.kill() 114 | 115 | # copy over the speedscope dir 116 | speedscope_copy = os.path.join(self.target_dir, "speedscope") 117 | if os.path.exists(speedscope_copy): 118 | shutil.rmtree(speedscope_copy) 119 | shutil.copytree(SPEEDSCOPE_APP, speedscope_copy) 120 | 121 | if not os.path.exists(self.profile_file): 122 | self.warning(f"Fail to find pyspy result at {self.profile_file}") 123 | return [] 124 | 125 | # create the js script that contains the base64-ed results 126 | with open(self.profile_file, "rb") as f: 127 | data = f.read() 128 | data = base64.b64encode(data).strip().decode() 129 | 130 | # create the javascript file 131 | js_data = f"speedscope.loadFileFromBase64('speedscope.json', '{data}')" 132 | result_js = os.path.abspath(os.path.join(self.target_dir, "results.js")) 133 | 134 | with open(result_js, "w") as f: 135 | f.write(js_data) 136 | 137 | with open(os.path.join(self.target_dir, "pyspy.html"), "w") as f: 138 | f.write( 139 | f'' 140 | ) 141 | 142 | return [ 143 | { 144 | "label": "Performance speedscope", 145 | "file": self.profile_file, 146 | "type": "artifact", 147 | }, 148 | { 149 | "label": "Py-spy Performance", 150 | "file": os.path.join(self.target_dir, "pyspy.html"), 151 | "type": "html", 152 | }, 153 | ] 154 | 155 | 156 | register_plugin(PySpy) 157 | -------------------------------------------------------------------------------- /perf8/plugins/base.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import importlib 20 | import asyncio 21 | import csv 22 | import os 23 | import contextlib 24 | 25 | from perf8.logger import logger 26 | 27 | 28 | _ASYNC_PLUGINS_INSTANCES = [] 29 | _PLUGINS_INSTANCES = {} 30 | 31 | 32 | def get_plugin_klass(fqn): 33 | module_name, klass_name = fqn.split(":") 34 | module = importlib.import_module(module_name) 35 | return getattr(module, klass_name) 36 | 37 | 38 | def set_plugins(plugins): 39 | for plugin in plugins: 40 | _PLUGINS_INSTANCES[plugin.name] = plugin 41 | 42 | 43 | async def enable(loop=None): 44 | if "PERF8" not in os.environ: 45 | return 46 | 47 | if loop is None: 48 | loop = asyncio.get_event_loop() 49 | 50 | for name in os.environ.get("PERF8_ASYNC_PLUGIN", "").split(","): 51 | name = name.strip() 52 | if name == "": 53 | continue 54 | plugin = _PLUGINS_INSTANCES[name] 55 | await plugin.enable(loop) 56 | _ASYNC_PLUGINS_INSTANCES.append(plugin) 57 | 58 | if len(_ASYNC_PLUGINS_INSTANCES) == 0: 59 | logger.warning("No perf8 async plugin was activated") 60 | 61 | 62 | async def disable(): 63 | for plugin in _ASYNC_PLUGINS_INSTANCES: 64 | await plugin.disable() 65 | _ASYNC_PLUGINS_INSTANCES[:] = [] 66 | 67 | 68 | @contextlib.asynccontextmanager 69 | async def measure(loop=None): 70 | await enable(loop) 71 | try: 72 | yield 73 | finally: 74 | await disable() 75 | 76 | 77 | _PLUGIN_CLASSES = [] 78 | 79 | 80 | def register_plugin(klass): 81 | if klass.supported: 82 | _PLUGIN_CLASSES.append(klass) 83 | 84 | 85 | def get_registered_plugins(): 86 | # this import will load all internal plugins modules 87 | # so they have a chance to register them selves 88 | from perf8 import plugins # NOQA 89 | 90 | return _PLUGIN_CLASSES 91 | 92 | 93 | class BasePlugin: 94 | name = "" 95 | in_process = False 96 | description = "" 97 | is_async = False 98 | priority = 0 99 | supported = True 100 | arguments = [] 101 | 102 | def __init__(self, args): 103 | self.args = args 104 | self.target_dir = args.target_dir 105 | self.enabled = False 106 | 107 | def check_pid(self, pid): 108 | try: 109 | os.kill(pid, 0) 110 | except OSError: 111 | return False 112 | return True 113 | 114 | def success(self): 115 | return True, "Looking good" 116 | 117 | def start(self, pid): 118 | self.enabled = True 119 | self._start(pid) 120 | 121 | def stop(self, pid=None): 122 | self.enabled = False 123 | if pid is not None: 124 | res = self._stop(pid) 125 | else: 126 | res = [] 127 | return res + [{"result": self.success(), "type": "result", "name": self.name}] 128 | 129 | @classmethod 130 | @property 131 | def fqn(cls): 132 | return f"{cls.__module__}:{cls.__qualname__}" 133 | 134 | def debug(self, msg): 135 | logger.debug(f"[{os.getpid()}][{self.name}] {msg}") 136 | 137 | def info(self, msg): 138 | logger.info(f"[{os.getpid()}][{self.name}] {msg}") 139 | 140 | def warning(self, msg): 141 | logger.warning(f"[{os.getpid()}][{self.name}] {msg}") 142 | 143 | def disable(self): 144 | if not self.enabled: 145 | return 146 | self._disable() 147 | self.enabled = False 148 | 149 | def enable(self): 150 | if self.enabled: 151 | return 152 | self._enable() 153 | self.enabled = True 154 | 155 | def _enable(self): 156 | raise NotImplementedError 157 | 158 | def _disable(self): 159 | raise NotImplementedError 160 | 161 | def report(self): 162 | raise NotImplementedError 163 | 164 | async def probe(self, pid): 165 | pass 166 | 167 | def generate_plots(self, path_or_rows, *graphs): 168 | # load lines once 169 | if isinstance(path_or_rows, str): 170 | rows = [] 171 | with open(path_or_rows) as csvfile: 172 | lines = csv.reader(csvfile, delimiter=",") 173 | for row in lines: 174 | rows.append(row) 175 | else: 176 | rows = path_or_rows 177 | 178 | self.info(f"Loaded {len(rows)} data points from {path_or_rows}") 179 | return [graph.generate(self, rows) for graph in graphs] 180 | 181 | 182 | class AsyncBasePlugin(BasePlugin): 183 | is_async = True 184 | 185 | async def disable(self): 186 | if not self.enabled: 187 | return 188 | await self._disable() 189 | 190 | async def enable(self, loop): 191 | if self.enabled: 192 | return 193 | await self._enable(loop) 194 | 195 | async def _enable(self, loop): 196 | raise NotImplementedError 197 | 198 | async def _disable(self): 199 | raise NotImplementedError 200 | -------------------------------------------------------------------------------- /perf8/reporter.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import os 20 | import csv 21 | import json 22 | import datetime 23 | from collections import defaultdict 24 | import base64 25 | import mimetypes 26 | import platform 27 | import psutil 28 | import humanize 29 | 30 | from jinja2 import Environment, FileSystemLoader 31 | from perf8 import __version__ 32 | from perf8.logger import logger 33 | from perf8.plot import Graph, Line 34 | from matplotlib.colors import BASE_COLORS 35 | 36 | 37 | HERE = os.path.dirname(__file__) 38 | 39 | # cgroup v1 file 40 | docker_memlimit = "/sys/fs/cgroup/memory/memory.limit_in_bytes" 41 | 42 | # cgroup v2 file 43 | docker_memlimit_v2 = "/sys/fs/cgroup/memory.max" 44 | 45 | if os.path.exists(docker_memlimit): 46 | with open(docker_memlimit) as f: 47 | system_memory = int(f.read().strip()) 48 | elif os.path.exists(docker_memlimit_v2): 49 | with open(docker_memlimit_v2) as f: 50 | system_memory = int(f.read().strip()) 51 | else: 52 | system_memory = psutil.virtual_memory().total 53 | 54 | 55 | class Datafile: 56 | def __init__(self, report_file, fields): 57 | self.report_file = report_file 58 | self.rows = fields 59 | self.writer = None 60 | self.count = 0 61 | 62 | def open(self): 63 | self.report_fd = open(self.report_file, "w") 64 | self.writer = csv.writer(self.report_fd) 65 | self.writer.writerow(self.rows) 66 | 67 | def add(self, values): 68 | try: 69 | self.writer.writerow(values) 70 | self.report_fd.flush() 71 | except ValueError: 72 | logger.warning(f"Failed to write in {self.report_file}") 73 | self.count += 1 74 | 75 | def close(self): 76 | self.report_fd.close() 77 | 78 | 79 | class Reporter: 80 | def __init__(self, args, execution_info, statsd_data): 81 | self.environment = Environment( 82 | loader=FileSystemLoader(os.path.join(HERE, "templates")) 83 | ) 84 | self.args = args 85 | self.execution_info = execution_info 86 | if args.max_duration == 0: 87 | self.overtime = False 88 | else: 89 | self.overtime = self.execution_info["duration_s"] > args.max_duration 90 | self.successes = 0 91 | self.failures = self.overtime and 1 or 0 92 | self.statsd_data = statsd_data 93 | 94 | @property 95 | def success(self): 96 | return self.failures == 0 and self.successes > 0 97 | 98 | def get_arguments(self): 99 | return vars(self.args) 100 | 101 | def get_system_info(self): 102 | # cpu_freq is broken on M1 see https://github.com/giampaolo/psutil/issues/1892 103 | # until this is resolved we can just ignore that info 104 | try: 105 | freq = f"{psutil.cpu_freq(percpu=False).current} Hz" 106 | except (FileNotFoundError, AttributeError): 107 | logger.warning("Could not get the CPU frequency") 108 | freq = "N/A" 109 | 110 | return { 111 | "OS Name": platform.system(), 112 | "Architecture": platform.architecture()[0], 113 | "Machine Type": platform.uname().machine, 114 | "Network Name": platform.uname().node, 115 | "Python Version": platform.python_version(), 116 | "Physical Memory": humanize.naturalsize(system_memory, binary=True), 117 | "Number of Cores": psutil.cpu_count(), 118 | "CPU Frequency": freq, 119 | } 120 | 121 | def render(self, name, **args): 122 | template = self.environment.get_template(name) 123 | args["args"] = self.args 124 | args["version"] = __version__ 125 | args["created_at"] = datetime.datetime.now().strftime("%d-%m-%y %H:%M:%S") 126 | args["success"] = self.success 127 | args["failures"] = self.failures 128 | args["successes"] = self.successes 129 | args["total"] = self.failures + self.successes 130 | if self.args.description is None: 131 | desc = "" 132 | elif os.path.exists(self.args.description): 133 | with open(self.args.description) as f: 134 | desc = f.read().strip() 135 | else: 136 | desc = self.args.description 137 | args["description"] = desc 138 | 139 | content = template.render(**args) 140 | target = os.path.join(self.args.target_dir, name) 141 | with open(target, "w") as f: 142 | f.write(content) 143 | return target 144 | 145 | def generate(self, run_summary, out_reports, plugins): 146 | reports = defaultdict(list) 147 | reports.update(out_reports) 148 | 149 | # read report.json to extend the list 150 | with open(run_summary) as f: 151 | data = json.loads(f.read()) 152 | 153 | for report in data["reports"]: 154 | reports[report["name"]].append(report) 155 | 156 | logger.info("Reports generated:") 157 | for plugin in plugins: 158 | if plugin.name not in reports: 159 | continue 160 | logger.info( 161 | f"Plugin {plugin.name} generated {len(reports[plugin.name])} report(s)" 162 | ) 163 | 164 | # generating index page 165 | if self.args.max_duration > 0: 166 | if self.overtime: 167 | result = ( 168 | False, 169 | f"Took over {humanize.precisedelta(self.args.max_duration)}", 170 | ) 171 | else: 172 | result = True, "Fast and Crisp" 173 | all_reports = [ 174 | {"num": 0, "result": result, "type": "result", "name": "general"} 175 | ] 176 | num = 1 177 | else: 178 | all_reports = [] 179 | num = 0 180 | 181 | for item in reports.values(): 182 | for report in item: 183 | report["num"] = num 184 | report["id"] = f"report-{num}" 185 | num += 1 186 | if report["type"] == "html": 187 | with open(report["file"]) as f: 188 | report["html"] = html = f.read() 189 | report["html_b64"] = base64.b64encode( 190 | html.encode("utf8") 191 | ).decode("utf-8") 192 | elif report["type"] == "image": 193 | with open(report["file"], "rb") as f: 194 | data = base64.b64encode(f.read()).decode("utf-8") 195 | report["image"] = data 196 | report["mimetype"] = mimetypes.guess_type(report["file"])[0] 197 | elif report["type"] == "artifact": 198 | report["file_size"] = humanize.naturalsize( 199 | os.stat(report["file"]).st_size, binary=True 200 | ) 201 | elif report["type"] == "result": 202 | if report["result"][0]: 203 | self.successes += 1 204 | else: 205 | self.failures += 1 206 | all_reports.append(report) 207 | 208 | # if we got stuff from statsd, we create one report per statsd type 209 | if self.statsd_data is not None: 210 | by_dates = [] 211 | counter_keys = [] 212 | 213 | for series in self.statsd_data.get_series(): 214 | by_date = {} 215 | for key, value in series["counters"].items(): 216 | if key not in counter_keys: 217 | counter_keys.append(key) 218 | by_date[key] = value 219 | 220 | by_dates.append((series["when"], by_date)) 221 | 222 | lines = [] 223 | colors = list(BASE_COLORS.keys()) 224 | 225 | for i, key in enumerate(counter_keys): 226 | samples = [] 227 | for y, (when, data) in enumerate(by_dates): 228 | samples.append((when, data.get(key, 0))) 229 | lines.append(Line(samples, key, None, colors[i])) 230 | 231 | graph = Graph( 232 | "Statsd Counters", 233 | self.args.target_dir, 234 | "statsd.png", 235 | "count", 236 | None, 237 | *lines, 238 | ) 239 | 240 | report = { 241 | "type": "image", 242 | "file": graph.generate(logger), 243 | "label": "Statsd Counters", 244 | } 245 | report["num"] = num 246 | report["id"] = f"report-{num}" 247 | num += 1 248 | 249 | with open(report["file"], "rb") as f: 250 | data = base64.b64encode(f.read()).decode("utf-8") 251 | report["image"] = data 252 | report["mimetype"] = mimetypes.guess_type(report["file"])[0] 253 | 254 | all_reports.append(report) 255 | 256 | def _s(report): 257 | if report["type"] == "artifact": 258 | suffix = "2" 259 | elif report["type"] == "image": 260 | suffix = "1" 261 | else: 262 | suffix = "0" 263 | 264 | return int(f'{suffix}{report["num"]}') 265 | 266 | all_reports.sort(key=_s) 267 | html_report = self.render( 268 | "index.html", 269 | reports=all_reports, 270 | plugins=plugins, 271 | system_info=self.get_system_info(), 272 | arguments=self.get_arguments(), 273 | execution_info=self.execution_info, 274 | ) 275 | 276 | return html_report 277 | -------------------------------------------------------------------------------- /perf8/runner.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | """ 20 | Wrapper script 21 | """ 22 | import os 23 | import argparse 24 | import json 25 | import shlex 26 | import sys 27 | from copy import copy 28 | import runpy 29 | import pathlib 30 | import signal 31 | import logging 32 | 33 | from perf8.logger import logger, set_logger 34 | from perf8.plugins.base import get_plugin_klass, set_plugins 35 | 36 | 37 | def run_script(script_file, script_args): 38 | saved = copy(sys.argv[:]) 39 | sys.path[0] = str(pathlib.Path(script_file).resolve().parent.absolute()) 40 | sys.argv[:] = [script_file, *script_args] 41 | runpy.run_path(script_file, run_name="__main__") 42 | sys.argv[:] = saved 43 | 44 | 45 | def main(): 46 | parser = argparse.ArgumentParser( 47 | description="Perf8 Wrapper", 48 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 49 | ) 50 | parser.add_argument( 51 | "-t", 52 | "--target-dir", 53 | default=os.getcwd(), 54 | type=str, 55 | help="target dir for results", 56 | ) 57 | parser.add_argument( 58 | "--ppid", 59 | type=int, 60 | help="Parent process id", 61 | ) 62 | parser.add_argument( 63 | "--plugins", 64 | type=str, 65 | default="", 66 | help="Plugins to use", 67 | ) 68 | parser.add_argument( 69 | "-v", 70 | "--verbose", 71 | action="count", 72 | default=0, 73 | help=( 74 | "Verbosity level. -v will display " 75 | "tracebacks. -vv requests and responses." 76 | ), 77 | ) 78 | parser.add_argument( 79 | "-r", 80 | "--report", 81 | default="report.json", 82 | type=str, 83 | help="report file", 84 | ) 85 | parser.add_argument( 86 | "-s", 87 | "--script", 88 | default="dummy.py --with-option -a=2", 89 | type=str, 90 | nargs=argparse.REMAINDER, 91 | help="Python script to run", 92 | ) 93 | 94 | # XXX pass-through perf8 args so the plugins can pick there options 95 | args = parser.parse_args() 96 | 97 | if args.verbose > 0: 98 | set_logger(logging.DEBUG) 99 | else: 100 | set_logger(logging.INFO) 101 | 102 | args.target_dir = os.path.abspath(args.target_dir) 103 | os.makedirs(args.target_dir, exist_ok=True) 104 | 105 | plugins = [ 106 | get_plugin_klass(fqn)(args) 107 | for fqn in args.plugins.split(",") 108 | if fqn.strip() != "" 109 | ] 110 | 111 | plugins.sort(key=lambda x: -x.priority) 112 | set_plugins(plugins) 113 | 114 | cmd_line = shlex.split(args.script[0]) 115 | 116 | script = cmd_line[0] 117 | script_args = cmd_line[1:] 118 | 119 | os.environ["PERF8"] = "1" 120 | async_plugins = [] 121 | 122 | for plugin in plugins: 123 | # async plugins are activated inside the target app 124 | if not plugin.is_async: 125 | plugin.enable() 126 | else: 127 | async_plugins.append(plugin.name) 128 | 129 | os.environ["PERF8_ASYNC_PLUGIN"] = ",".join(async_plugins) 130 | os.environ["PERF8"] = "1" 131 | 132 | # we disable plugins right away on SIGTERM / SIGINT 133 | def _exit(signum, frame): 134 | for plugin in reversed(plugins): 135 | if not plugin.is_async and plugin.in_process: 136 | plugin.disable() 137 | # re-raise interrupt 138 | raise SystemExit() 139 | 140 | signal.signal(signal.SIGINT, _exit) 141 | signal.signal(signal.SIGTERM, _exit) 142 | 143 | # no in-process plugins, we just run it 144 | try: 145 | run_script(script, script_args) 146 | finally: 147 | logger.info(f"Script is over -- sending a signal to {args.ppid}") 148 | 149 | # script is over, send a signal to the parent 150 | try: 151 | os.kill(args.ppid, signal.SIGUSR1) 152 | except ProcessLookupError: 153 | logger.warning("Could not find parent process") 154 | 155 | for plugin in reversed(plugins): 156 | if not plugin.is_async and plugin.in_process: 157 | plugin.disable() 158 | 159 | # sending back the reports to the main process through json 160 | reports = [] 161 | for plugin in plugins: 162 | for report in plugin.report(): 163 | report["name"] = plugin.name 164 | reports.append(report) 165 | reports.extend(plugin.stop()) 166 | 167 | report = os.path.join(args.target_dir, args.report) 168 | with open(report, "w") as f: 169 | f.write(json.dumps({"reports": reports})) 170 | logger.info(f"Wrote {report}") 171 | 172 | 173 | if __name__ == "__main__": 174 | main() 175 | -------------------------------------------------------------------------------- /perf8/speedscope/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jamie Wong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /perf8/speedscope/README: -------------------------------------------------------------------------------- 1 | This is a self-contained release of https://github.com/jlfwong/speedscope. 2 | To use it, open index.html in Chrome or Firefox. 3 | -------------------------------------------------------------------------------- /perf8/speedscope/favicon-16x16.f74b3187.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/perf8/ac27d20d5779c7060f1b514ff25410a579a05be1/perf8/speedscope/favicon-16x16.f74b3187.png -------------------------------------------------------------------------------- /perf8/speedscope/favicon-32x32.bc503437.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/perf8/ac27d20d5779c7060f1b514ff25410a579a05be1/perf8/speedscope/favicon-32x32.bc503437.png -------------------------------------------------------------------------------- /perf8/speedscope/file-format-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "definitions": { 4 | "CloseFrameEvent": { 5 | "properties": { 6 | "at": { 7 | "title": "at", 8 | "type": "number" 9 | }, 10 | "frame": { 11 | "title": "frame", 12 | "type": "number" 13 | }, 14 | "type": { 15 | "enum": [ 16 | "C" 17 | ], 18 | "title": "type", 19 | "type": "string" 20 | } 21 | }, 22 | "required": [ 23 | "at", 24 | "frame", 25 | "type" 26 | ], 27 | "title": "CloseFrameEvent", 28 | "type": "object" 29 | }, 30 | "FileFormat.EventType": { 31 | "enum": [ 32 | "C", 33 | "O" 34 | ], 35 | "title": "FileFormat.EventType", 36 | "type": "string" 37 | }, 38 | "FileFormat.EventedProfile": { 39 | "properties": { 40 | "endValue": { 41 | "title": "endValue", 42 | "type": "number" 43 | }, 44 | "events": { 45 | "items": { 46 | "anyOf": [ 47 | { 48 | "$ref": "#/definitions/OpenFrameEvent" 49 | }, 50 | { 51 | "$ref": "#/definitions/CloseFrameEvent" 52 | } 53 | ] 54 | }, 55 | "title": "events", 56 | "type": "array" 57 | }, 58 | "name": { 59 | "title": "name", 60 | "type": "string" 61 | }, 62 | "startValue": { 63 | "title": "startValue", 64 | "type": "number" 65 | }, 66 | "type": { 67 | "enum": [ 68 | "evented" 69 | ], 70 | "title": "type", 71 | "type": "string" 72 | }, 73 | "unit": { 74 | "$ref": "#/definitions/FileFormat.ValueUnit", 75 | "title": "unit" 76 | } 77 | }, 78 | "required": [ 79 | "endValue", 80 | "events", 81 | "name", 82 | "startValue", 83 | "type", 84 | "unit" 85 | ], 86 | "title": "FileFormat.EventedProfile", 87 | "type": "object" 88 | }, 89 | "FileFormat.File": { 90 | "properties": { 91 | "$schema": { 92 | "enum": [ 93 | "https://www.speedscope.app/file-format-schema.json" 94 | ], 95 | "title": "$schema", 96 | "type": "string" 97 | }, 98 | "activeProfileIndex": { 99 | "title": "activeProfileIndex", 100 | "type": "number" 101 | }, 102 | "exporter": { 103 | "title": "exporter", 104 | "type": "string" 105 | }, 106 | "name": { 107 | "title": "name", 108 | "type": "string" 109 | }, 110 | "profiles": { 111 | "items": { 112 | "anyOf": [ 113 | { 114 | "$ref": "#/definitions/FileFormat.EventedProfile" 115 | }, 116 | { 117 | "$ref": "#/definitions/FileFormat.SampledProfile" 118 | } 119 | ] 120 | }, 121 | "title": "profiles", 122 | "type": "array" 123 | }, 124 | "shared": { 125 | "properties": { 126 | "frames": { 127 | "items": { 128 | "$ref": "#/definitions/FileFormat.Frame" 129 | }, 130 | "title": "frames", 131 | "type": "array" 132 | } 133 | }, 134 | "required": [ 135 | "frames" 136 | ], 137 | "title": "shared", 138 | "type": "object" 139 | } 140 | }, 141 | "required": [ 142 | "$schema", 143 | "profiles", 144 | "shared" 145 | ], 146 | "title": "FileFormat.File", 147 | "type": "object" 148 | }, 149 | "FileFormat.Frame": { 150 | "properties": { 151 | "col": { 152 | "title": "col", 153 | "type": "number" 154 | }, 155 | "file": { 156 | "title": "file", 157 | "type": "string" 158 | }, 159 | "line": { 160 | "title": "line", 161 | "type": "number" 162 | }, 163 | "name": { 164 | "title": "name", 165 | "type": "string" 166 | } 167 | }, 168 | "required": [ 169 | "name" 170 | ], 171 | "title": "FileFormat.Frame", 172 | "type": "object" 173 | }, 174 | "FileFormat.IProfile": { 175 | "properties": { 176 | "type": { 177 | "$ref": "#/definitions/FileFormat.ProfileType", 178 | "title": "type" 179 | } 180 | }, 181 | "required": [ 182 | "type" 183 | ], 184 | "title": "FileFormat.IProfile", 185 | "type": "object" 186 | }, 187 | "FileFormat.Profile": { 188 | "anyOf": [ 189 | { 190 | "$ref": "#/definitions/FileFormat.EventedProfile" 191 | }, 192 | { 193 | "$ref": "#/definitions/FileFormat.SampledProfile" 194 | } 195 | ] 196 | }, 197 | "FileFormat.ProfileType": { 198 | "enum": [ 199 | "evented", 200 | "sampled" 201 | ], 202 | "title": "FileFormat.ProfileType", 203 | "type": "string" 204 | }, 205 | "FileFormat.SampledProfile": { 206 | "properties": { 207 | "endValue": { 208 | "title": "endValue", 209 | "type": "number" 210 | }, 211 | "name": { 212 | "title": "name", 213 | "type": "string" 214 | }, 215 | "samples": { 216 | "items": { 217 | "items": { 218 | "type": "number" 219 | }, 220 | "type": "array" 221 | }, 222 | "title": "samples", 223 | "type": "array" 224 | }, 225 | "startValue": { 226 | "title": "startValue", 227 | "type": "number" 228 | }, 229 | "type": { 230 | "enum": [ 231 | "sampled" 232 | ], 233 | "title": "type", 234 | "type": "string" 235 | }, 236 | "unit": { 237 | "$ref": "#/definitions/FileFormat.ValueUnit", 238 | "title": "unit" 239 | }, 240 | "weights": { 241 | "items": { 242 | "type": "number" 243 | }, 244 | "title": "weights", 245 | "type": "array" 246 | } 247 | }, 248 | "required": [ 249 | "endValue", 250 | "name", 251 | "samples", 252 | "startValue", 253 | "type", 254 | "unit", 255 | "weights" 256 | ], 257 | "title": "FileFormat.SampledProfile", 258 | "type": "object" 259 | }, 260 | "FileFormat.ValueUnit": { 261 | "enum": [ 262 | "bytes", 263 | "microseconds", 264 | "milliseconds", 265 | "nanoseconds", 266 | "none", 267 | "seconds" 268 | ], 269 | "title": "FileFormat.ValueUnit", 270 | "type": "string" 271 | }, 272 | "IEvent": { 273 | "properties": { 274 | "at": { 275 | "title": "at", 276 | "type": "number" 277 | }, 278 | "type": { 279 | "$ref": "#/definitions/FileFormat.EventType", 280 | "title": "type" 281 | } 282 | }, 283 | "required": [ 284 | "at", 285 | "type" 286 | ], 287 | "title": "IEvent", 288 | "type": "object" 289 | }, 290 | "OpenFrameEvent": { 291 | "properties": { 292 | "at": { 293 | "title": "at", 294 | "type": "number" 295 | }, 296 | "frame": { 297 | "title": "frame", 298 | "type": "number" 299 | }, 300 | "type": { 301 | "enum": [ 302 | "O" 303 | ], 304 | "title": "type", 305 | "type": "string" 306 | } 307 | }, 308 | "required": [ 309 | "at", 310 | "frame", 311 | "type" 312 | ], 313 | "title": "OpenFrameEvent", 314 | "type": "object" 315 | }, 316 | "SampledStack": { 317 | "items": { 318 | "type": "number" 319 | }, 320 | "type": "array" 321 | } 322 | }, 323 | "$ref": "#/definitions/FileFormat.File" 324 | } 325 | -------------------------------------------------------------------------------- /perf8/speedscope/index.html: -------------------------------------------------------------------------------- 1 | speedscope 2 | -------------------------------------------------------------------------------- /perf8/speedscope/release.txt: -------------------------------------------------------------------------------- 1 | speedscope@1.15.0 2 | Sat Oct 22 00:19:31 HKT 2022 3 | 81a6f29ad1eb632683429084bd6c5f497667fb5e 4 | -------------------------------------------------------------------------------- /perf8/speedscope/reset.8c46b7a1.css: -------------------------------------------------------------------------------- 1 | a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:initial}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:"";content:none}table{border-collapse:collapse;border-spacing:0}html{overflow:hidden}body,html{height:100%}body{overflow:auto} 2 | /*# sourceMappingURL=reset.8c46b7a1.css.map */ -------------------------------------------------------------------------------- /perf8/speedscope/reset.8c46b7a1.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["reset.css"],"names":[],"mappings":"AAIA,2ZAaC,QAAS,CACT,SAAU,CACV,QAAS,CACT,cAAe,CACf,YAAa,CACb,sBACD,CAEA,8EAEC,aACD,CACA,KACC,aACD,CACA,MACC,eACD,CACA,aACC,WACD,CACA,oDAEC,UAAW,CACX,YACD,CACA,MACC,wBAAyB,CACzB,gBACD,CAIA,KACI,eAEJ,CACA,UAFI,WAKJ,CAHA,KAEI,aACJ","file":"reset.8c46b7a1.css","sourceRoot":"../../assets","sourcesContent":["/* http://meyerweb.com/eric/tools/css/reset/\n v2.0 | 20110126\n License: none (public domain)\n*/\nhtml, body, div, span, applet, object, iframe,\nh1, h2, h3, h4, h5, h6, p, blockquote, pre,\na, abbr, acronym, address, big, cite, code,\ndel, dfn, em, img, ins, kbd, q, s, samp,\nsmall, strike, strong, sub, sup, tt, var,\nb, u, i, center,\ndl, dt, dd, ol, ul, li,\nfieldset, form, label, legend,\ntable, caption, tbody, tfoot, thead, tr, th, td,\narticle, aside, canvas, details, embed,\nfigure, figcaption, footer, header, hgroup,\nmenu, nav, output, ruby, section, summary,\ntime, mark, audio, video {\n\tmargin: 0;\n\tpadding: 0;\n\tborder: 0;\n\tfont-size: 100%;\n\tfont: inherit;\n\tvertical-align: baseline;\n}\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure,\nfooter, header, hgroup, menu, nav, section {\n\tdisplay: block;\n}\nbody {\n\tline-height: 1;\n}\nol, ul {\n\tlist-style: none;\n}\nblockquote, q {\n\tquotes: none;\n}\nblockquote:before, blockquote:after,\nq:before, q:after {\n\tcontent: '';\n\tcontent: none;\n}\ntable {\n\tborder-collapse: collapse;\n\tborder-spacing: 0;\n}\n\n/* Prevent overscrolling */\n/* https://stackoverflow.com/a/17899813 */\nhtml {\n overflow: hidden;\n height: 100%;\n}\nbody {\n height: 100%;\n overflow: auto;\n}"]} -------------------------------------------------------------------------------- /perf8/speedscope/source-map.438fa06b.js: -------------------------------------------------------------------------------- 1 | parcelRequire=function(e,r,t,n){var i,o="function"==typeof parcelRequire&&parcelRequire,u="function"==typeof require&&require;function f(t,n){if(!r[t]){if(!e[t]){var i="function"==typeof parcelRequire&&parcelRequire;if(!n&&i)return i(t,!0);if(o)return o(t,!0);if(u&&"string"==typeof t)return u(t);var c=new Error("Cannot find module '"+t+"'");throw c.code="MODULE_NOT_FOUND",c}p.resolve=function(r){return e[t][1][r]||r},p.cache={};var l=r[t]=new f.Module(t);e[t][0].call(l.exports,p,l,l.exports,this)}return r[t].exports;function p(e){return f(p.resolve(e))}}f.isParcelRequire=!0,f.Module=function(e){this.id=e,this.bundle=f,this.exports={}},f.modules=e,f.cache=r,f.parent=o,f.register=function(r,t){e[r]=[function(e,r){r.exports=t},{}]};for(var c=0;c>1;return 1==(1&e)?-r:r}exports.encode=function(n){var d,a="",c=i(n);do{d=c&o,(c>>>=r)>0&&(d|=t),a+=e.encode(d)}while(c>0);return a},exports.decode=function(n,i,a){var c,u,h=n.length,s=0,v=0;do{if(i>=h)throw new Error("Expected more digits in base 64 VLQ value.");if(-1===(u=e.decode(n.charCodeAt(i++))))throw new Error("Invalid base64 digit: "+n.charAt(i-1));c=!!(u&t),s+=(u&=o)<=0;s--)"."===(a=u[s])?u.splice(s,1):".."===a?c++:c>0&&(""===a?(u.splice(s+1,c),c=0):(u.splice(s,2),c--));return""===(r=u.join("/"))&&(r=i?"/":"."),n?(n.path=r,o(n)):r}function i(e,r){""===e&&(e="."),""===r&&(r=".");var i=t(r),u=t(e);if(u&&(e=u.path||"/"),i&&!i.scheme)return u&&(i.scheme=u.scheme),o(i);if(i||r.match(n))return r;if(u&&!u.host&&!u.path)return u.host=r,o(u);var c="/"===r.charAt(0)?r:a(e.replace(/\/+$/,"")+"/"+r);return u?(u.path=c,o(u)):c}function u(e,r){""===e&&(e="."),e=e.replace(/\/$/,"");for(var n=0;0!==r.indexOf(e+"/");){var t=e.lastIndexOf("/");if(t<0)return r;if((e=e.slice(0,t)).match(/^([^\/]+:\/)?\/*$/))return r;++n}return Array(n+1).join("../")+r.substr(e.length+1)}exports.urlParse=t,exports.urlGenerate=o,exports.normalize=a,exports.join=i,exports.isAbsolute=function(e){return"/"===e.charAt(0)||r.test(e)},exports.relative=u;var c=!("__proto__"in Object.create(null));function s(e){return e}function l(e){return p(e)?"$"+e:e}function h(e){return p(e)?e.slice(1):e}function p(e){if(!e)return!1;var r=e.length;if(r<9)return!1;if(95!==e.charCodeAt(r-1)||95!==e.charCodeAt(r-2)||111!==e.charCodeAt(r-3)||116!==e.charCodeAt(r-4)||111!==e.charCodeAt(r-5)||114!==e.charCodeAt(r-6)||112!==e.charCodeAt(r-7)||95!==e.charCodeAt(r-8)||95!==e.charCodeAt(r-9))return!1;for(var n=r-10;n>=0;n--)if(36!==e.charCodeAt(n))return!1;return!0}function f(e,r,n){var t=d(e.source,r.source);return 0!==t?t:0!==(t=e.originalLine-r.originalLine)?t:0!==(t=e.originalColumn-r.originalColumn)||n?t:0!==(t=e.generatedColumn-r.generatedColumn)?t:0!==(t=e.generatedLine-r.generatedLine)?t:d(e.name,r.name)}function g(e,r,n){var t=e.generatedLine-r.generatedLine;return 0!==t?t:0!==(t=e.generatedColumn-r.generatedColumn)||n?t:0!==(t=d(e.source,r.source))?t:0!==(t=e.originalLine-r.originalLine)?t:0!==(t=e.originalColumn-r.originalColumn)?t:d(e.name,r.name)}function d(e,r){return e===r?0:null===e?1:null===r?-1:e>r?1:-1}function m(e,r){var n=e.generatedLine-r.generatedLine;return 0!==n?n:0!==(n=e.generatedColumn-r.generatedColumn)?n:0!==(n=d(e.source,r.source))?n:0!==(n=e.originalLine-r.originalLine)?n:0!==(n=e.originalColumn-r.originalColumn)?n:d(e.name,r.name)}function C(e){return JSON.parse(e.replace(/^\)]}'[^\n]*\n/,""))}function v(e,r,n){if(r=r||"",e&&("/"!==e[e.length-1]&&"/"!==r[0]&&(e+="/"),r=e+r),n){var u=t(n);if(!u)throw new Error("sourceMapURL could not be parsed");if(u.path){var c=u.path.lastIndexOf("/");c>=0&&(u.path=u.path.substring(0,c+1))}r=i(o(u),r)}return a(r)}exports.toSetString=c?s:l,exports.fromSetString=c?s:h,exports.compareByOriginalPositions=f,exports.compareByGeneratedPositionsDeflated=g,exports.compareByGeneratedPositionsInflated=m,exports.parseSourceMapInput=C,exports.computeSourceURL=v; 7 | },{}],"dghU":[function(require,module,exports) { 8 | var t=require("./util"),e=Object.prototype.hasOwnProperty,r="undefined"!=typeof Map;function n(){this._array=[],this._set=r?new Map:Object.create(null)}n.fromArray=function(t,e){for(var r=new n,i=0,s=t.length;i=0)return i}else{var s=t.toSetString(n);if(e.call(this._set,s))return this._set[s]}throw new Error('"'+n+'" is not in the set.')},n.prototype.at=function(t){if(t>=0&&ta||n==a&&s>=o||t.compareByGeneratedPositionsInflated(e,r)<=0}function r(){this._array=[],this._sorted=!0,this._last={generatedLine:-1,generatedColumn:0}}r.prototype.unsortedForEach=function(t,e){this._array.forEach(t,e)},r.prototype.add=function(t){e(this._last,t)?(this._last=t,this._array.push(t)):(this._sorted=!1,this._array.push(t))},r.prototype.toArray=function(){return this._sorted||(this._array.sort(t.compareByGeneratedPositionsInflated),this._sorted=!0),this._array},exports.MappingList=r; 11 | },{"./util":"XUQW"}],"Wwhl":[function(require,module,exports) { 12 | var e=require("./base64-vlq"),n=require("./util"),o=require("./array-set").ArraySet,t=require("./mapping-list").MappingList;function r(e){e||(e={}),this._file=n.getArg(e,"file",null),this._sourceRoot=n.getArg(e,"sourceRoot",null),this._skipValidation=n.getArg(e,"skipValidation",!1),this._sources=new o,this._names=new o,this._mappings=new t,this._sourcesContents=null}r.prototype._version=3,r.fromSourceMap=function(e){var o=e.sourceRoot,t=new r({file:e.file,sourceRoot:o});return e.eachMapping(function(e){var r={generated:{line:e.generatedLine,column:e.generatedColumn}};null!=e.source&&(r.source=e.source,null!=o&&(r.source=n.relative(o,r.source)),r.original={line:e.originalLine,column:e.originalColumn},null!=e.name&&(r.name=e.name)),t.addMapping(r)}),e.sources.forEach(function(r){var i=r;null!==o&&(i=n.relative(o,r)),t._sources.has(i)||t._sources.add(i);var s=e.sourceContentFor(r);null!=s&&t.setSourceContent(r,s)}),t},r.prototype.addMapping=function(e){var o=n.getArg(e,"generated"),t=n.getArg(e,"original",null),r=n.getArg(e,"source",null),i=n.getArg(e,"name",null);this._skipValidation||this._validateMapping(o,t,r,i),null!=r&&(r=String(r),this._sources.has(r)||this._sources.add(r)),null!=i&&(i=String(i),this._names.has(i)||this._names.add(i)),this._mappings.add({generatedLine:o.line,generatedColumn:o.column,originalLine:null!=t&&t.line,originalColumn:null!=t&&t.column,source:r,name:i})},r.prototype.setSourceContent=function(e,o){var t=e;null!=this._sourceRoot&&(t=n.relative(this._sourceRoot,t)),null!=o?(this._sourcesContents||(this._sourcesContents=Object.create(null)),this._sourcesContents[n.toSetString(t)]=o):this._sourcesContents&&(delete this._sourcesContents[n.toSetString(t)],0===Object.keys(this._sourcesContents).length&&(this._sourcesContents=null))},r.prototype.applySourceMap=function(e,t,r){var i=t;if(null==t){if(null==e.file)throw new Error('SourceMapGenerator.prototype.applySourceMap requires either an explicit source file, or the source map\'s "file" property. Both were omitted.');i=e.file}var s=this._sourceRoot;null!=s&&(i=n.relative(s,i));var l=new o,u=new o;this._mappings.unsortedForEach(function(o){if(o.source===i&&null!=o.originalLine){var t=e.originalPositionFor({line:o.originalLine,column:o.originalColumn});null!=t.source&&(o.source=t.source,null!=r&&(o.source=n.join(r,o.source)),null!=s&&(o.source=n.relative(s,o.source)),o.originalLine=t.line,o.originalColumn=t.column,null!=t.name&&(o.name=t.name))}var a=o.source;null==a||l.has(a)||l.add(a);var c=o.name;null==c||u.has(c)||u.add(c)},this),this._sources=l,this._names=u,e.sources.forEach(function(o){var t=e.sourceContentFor(o);null!=t&&(null!=r&&(o=n.join(r,o)),null!=s&&(o=n.relative(s,o)),this.setSourceContent(o,t))},this)},r.prototype._validateMapping=function(e,n,o,t){if(n&&"number"!=typeof n.line&&"number"!=typeof n.column)throw new Error("original.line and original.column are not numbers -- you probably meant to omit the original mapping entirely and only map the generated position. If so, pass null for the original mapping instead of an object with empty or null values.");if((!(e&&"line"in e&&"column"in e&&e.line>0&&e.column>=0)||n||o||t)&&!(e&&"line"in e&&"column"in e&&n&&"line"in n&&"column"in n&&e.line>0&&e.column>=0&&n.line>0&&n.column>=0&&o))throw new Error("Invalid mapping: "+JSON.stringify({generated:e,source:o,original:n,name:t}))},r.prototype._serializeMappings=function(){for(var o,t,r,i,s=0,l=1,u=0,a=0,c=0,p=0,g="",h=this._mappings.toArray(),m=0,f=h.length;m0){if(!n.compareByGeneratedPositionsInflated(t,h[m-1]))continue;o+=","}o+=e.encode(t.generatedColumn-s),s=t.generatedColumn,null!=t.source&&(i=this._sources.indexOf(t.source),o+=e.encode(i-p),p=i,o+=e.encode(t.originalLine-1-a),a=t.originalLine-1,o+=e.encode(t.originalColumn-u),u=t.originalColumn,null!=t.name&&(r=this._names.indexOf(t.name),o+=e.encode(r-c),c=r)),g+=o}return g},r.prototype._generateSourcesContent=function(e,o){return e.map(function(e){if(!this._sourcesContents)return null;null!=o&&(e=n.relative(o,e));var t=n.toSetString(e);return Object.prototype.hasOwnProperty.call(this._sourcesContents,t)?this._sourcesContents[t]:null},this)},r.prototype.toJSON=function(){var e={version:this._version,sources:this._sources.toArray(),names:this._names.toArray(),mappings:this._serializeMappings()};return null!=this._file&&(e.file=this._file),null!=this._sourceRoot&&(e.sourceRoot=this._sourceRoot),this._sourcesContents&&(e.sourcesContent=this._generateSourcesContent(e.sources,e.sourceRoot)),e},r.prototype.toString=function(){return JSON.stringify(this.toJSON())},exports.SourceMapGenerator=r; 13 | },{"./base64-vlq":"iWlY","./util":"XUQW","./array-set":"dghU","./mapping-list":"AUTm"}],"rdpJ":[function(require,module,exports) { 14 | function r(t,e,E,n,o,_){var U=Math.floor((e-t)/2)+t,s=o(E,n[U],!0);return 0===s?U:s>0?e-U>1?r(U,e,E,n,o,_):_==exports.LEAST_UPPER_BOUND?e1?r(t,U,E,n,o,_):_==exports.LEAST_UPPER_BOUND?U:t<0?-1:t}exports.GREATEST_LOWER_BOUND=1,exports.LEAST_UPPER_BOUND=2,exports.search=function(t,e,E,n){if(0===e.length)return-1;var o=r(-1,e.length,t,e,E,n||exports.GREATEST_LOWER_BOUND);if(o<0)return-1;for(;o-1>=0&&0===E(e[o],e[o-1],!0);)--o;return o}; 15 | },{}],"lFls":[function(require,module,exports) { 16 | function n(n,r,t){var o=n[r];n[r]=n[t],n[t]=o}function r(n,r){return Math.round(n+Math.random()*(r-n))}function t(o,a,u,f){if(u=0){var a=this._originalMappings[s];if(void 0===r.column)for(var u=a.originalLine;a&&a.originalLine===u;)i.push({line:e.getArg(a,"generatedLine",null),column:e.getArg(a,"generatedColumn",null),lastColumn:e.getArg(a,"lastGeneratedColumn",null)}),a=this._originalMappings[++s];else for(var l=a.originalColumn;a&&a.originalLine===t&&a.originalColumn==l;)i.push({line:e.getArg(a,"generatedLine",null),column:e.getArg(a,"generatedColumn",null),lastColumn:e.getArg(a,"lastGeneratedColumn",null)}),a=this._originalMappings[++s]}return i},exports.SourceMapConsumer=i,s.prototype=Object.create(i.prototype),s.prototype.consumer=i,s.prototype._findSourceIndex=function(n){var r,t=n;if(null!=this.sourceRoot&&(t=e.relative(this.sourceRoot,t)),this._sources.has(t))return this._sources.indexOf(t);for(r=0;r1&&(i.source=f+u[1],f+=u[1],i.originalLine=h+u[2],h=i.originalLine,i.originalLine+=1,i.originalColumn=m+u[3],m=i.originalColumn,u.length>4&&(i.name=_+u[4],_+=u[4])),y.push(i),"number"==typeof i.originalLine&&v.push(i)}o(y,e.compareByGeneratedPositionsDeflated),this.__generatedMappings=y,o(v,e.compareByOriginalPositions),this.__originalMappings=v},s.prototype._findMapping=function(e,r,t,o,i,s){if(e[t]<=0)throw new TypeError("Line must be greater than or equal to 1, got "+e[t]);if(e[o]<0)throw new TypeError("Column must be greater than or equal to 0, got "+e[o]);return n.search(e,r,i,s)},s.prototype.computeColumnSpans=function(){for(var e=0;e=0){var o=this._generatedMappings[t];if(o.generatedLine===r.generatedLine){var s=e.getArg(o,"source",null);null!==s&&(s=this._sources.at(s),s=e.computeSourceURL(this.sourceRoot,s,this._sourceMapURL));var a=e.getArg(o,"name",null);return null!==a&&(a=this._names.at(a)),{source:s,line:e.getArg(o,"originalLine",null),column:e.getArg(o,"originalColumn",null),name:a}}}return{source:null,line:null,column:null,name:null}},s.prototype.hasContentsOfAllSources=function(){return!!this.sourcesContent&&(this.sourcesContent.length>=this._sources.size()&&!this.sourcesContent.some(function(e){return null==e}))},s.prototype.sourceContentFor=function(n,r){if(!this.sourcesContent)return null;var t=this._findSourceIndex(n);if(t>=0)return this.sourcesContent[t];var o,i=n;if(null!=this.sourceRoot&&(i=e.relative(this.sourceRoot,i)),null!=this.sourceRoot&&(o=e.urlParse(this.sourceRoot))){var s=i.replace(/^file:\/\//,"");if("file"==o.scheme&&this._sources.has(s))return this.sourcesContent[this._sources.indexOf(s)];if((!o.path||"/"==o.path)&&this._sources.has("/"+i))return this.sourcesContent[this._sources.indexOf("/"+i)]}if(r)return null;throw new Error('"'+i+'" is not in the SourceMap.')},s.prototype.generatedPositionFor=function(n){var r=e.getArg(n,"source");if((r=this._findSourceIndex(r))<0)return{line:null,column:null,lastColumn:null};var t={source:r,originalLine:e.getArg(n,"line"),originalColumn:e.getArg(n,"column")},o=this._findMapping(t,this._originalMappings,"originalLine","originalColumn",e.compareByOriginalPositions,e.getArg(n,"bias",i.GREATEST_LOWER_BOUND));if(o>=0){var s=this._originalMappings[o];if(s.source===t.source)return{line:e.getArg(s,"generatedLine",null),column:e.getArg(s,"generatedColumn",null),lastColumn:e.getArg(s,"lastGeneratedColumn",null)}}return{line:null,column:null,lastColumn:null}},exports.BasicSourceMapConsumer=s,u.prototype=Object.create(i.prototype),u.prototype.constructor=i,u.prototype._version=3,Object.defineProperty(u.prototype,"sources",{get:function(){for(var e=[],n=0;n=0;e--)this.prepend(n[e]);else{if(!n[o]&&"string"!=typeof n)throw new TypeError("Expected a SourceNode, string, or an array of SourceNodes and strings. Got "+n);this.children.unshift(n)}return this},i.prototype.walk=function(n){for(var e,r=0,t=this.children.length;r0){for(e=[],r=0;r 2 | 3 | 4 | 5 | 6 | Perf8 - {{ args.title }} 7 | 8 | 9 | 10 | 11 | {% block content %} 12 | 13 | {% block body %} 14 | 15 | {% endblock body %} 16 | 17 | {% endblock content %} 18 | 19 | 20 | -------------------------------------------------------------------------------- /perf8/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 | 5 | 31 | 32 | 33 | 46 | 47 | 66 | 67 | 68 |
69 |
70 |

71 |

Summary

72 |
73 |
74 | 75 | {% if description %} 76 |
{{description}}
77 | {% endif %} 78 | 79 | {% if success %} 80 |
SUCCESS! {{successes}}/{{total}}. The test took {{execution_info['duration']}}
81 | {% else %} 82 |
FAILURE! {{successes}}/{{total}}. The test took {{execution_info['duration']}}
83 | {% endif %} 84 | 85 |

86 | Results 87 |

88 |
    89 | {% for report in reports %} 90 | {% if report['type'] == 'result' %} 91 |
  • 92 | {% if report['result'][0] %}✅ {% else %}❌ {% endif %} 93 | {{report['name']}} → {{report['result'][1]}} 94 |
  • 95 | {% endif %} 96 | {% endfor %} 97 |
98 | 99 | 100 |

101 | System info 102 |

103 |
    104 | {% for info, value in system_info.items() %} 105 |
  • {{info}} → {{value}}
  • 106 | {% endfor %} 107 |
108 | 109 |

110 | Command executed 111 |

112 |
perf8{% for name, value in arguments.items() %} --{{name.replace('_', '-')}}={{value}}{% endfor %}
113 | 114 |

115 | Plugins used 116 |

117 |
    118 | {% for plugin in plugins %} 119 |
  • {{plugin.name}} → {{plugin.description}}
  • 120 | {% endfor %} 121 |
122 | 123 |

124 | Artifacts 125 |

126 | 135 | 136 |
137 | 138 | {% for report in reports %} 139 | 140 | {% if report['type'] == 'image' %} 141 | 144 | {% endif %} 145 | 146 | {% if report['type'] == 'html' %} 147 | 150 | {% endif %} 151 | 152 | {% endfor %} 153 | 154 | 155 | {% endblock body %} 156 | -------------------------------------------------------------------------------- /perf8/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/perf8/ac27d20d5779c7060f1b514ff25410a579a05be1/perf8/tests/__init__.py -------------------------------------------------------------------------------- /perf8/tests/ademo.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | 20 | """ 21 | Demo script to test perf8 (async) 22 | """ 23 | import random 24 | import os 25 | import asyncio 26 | from time import time 27 | 28 | from perf8 import enable, disable 29 | 30 | 31 | data = [] 32 | RANGE = int(os.environ.get("RANGE", 50000)) 33 | 34 | 35 | def get_random(min, max): 36 | return random.randint(min, max) 37 | 38 | 39 | def math1(x, y): 40 | x * y 41 | 42 | 43 | def math2(x, y): 44 | y**x 45 | 46 | 47 | def do_math(x, y): 48 | math1(x, y) 49 | math2(x, y) 50 | 51 | 52 | async def add_mem1(): 53 | data.append("r" * get_random(100, 2000)) 54 | 55 | 56 | async def add_mem2(): 57 | data.append("r" * get_random(100, 2000)) 58 | await add_mem3() 59 | 60 | 61 | async def add_mem3(): 62 | data.append("r" * get_random(100, 20000)) 63 | 64 | 65 | async def some_math(i): 66 | if get_random(1, 20) == 2: 67 | print(f"[{os.getpid()}] Busy adding data! ({i}/{RANGE})") 68 | try: 69 | do_math(i, RANGE) 70 | except Exception as e: 71 | print(e) 72 | 73 | 74 | # generates one GiB in RSS 75 | async def main(): 76 | await enable() 77 | 78 | for i in range(RANGE): 79 | t = asyncio.create_task(add_mem1()) 80 | t2 = asyncio.create_task(add_mem2()) 81 | if RANGE == 345: 82 | # blocks for 2 secs 83 | time.sleep(2) 84 | 85 | t3 = asyncio.create_task(some_math(i)) 86 | await asyncio.gather(t, t2, t3) 87 | 88 | await disable() 89 | 90 | 91 | if __name__ == "__main__": 92 | asyncio.run(main()) 93 | print("Bye!") 94 | -------------------------------------------------------------------------------- /perf8/tests/demo.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | 20 | """ 21 | Demo script to test perf8 22 | """ 23 | import random 24 | import os 25 | import tempfile 26 | import shutil 27 | import string 28 | import statsd 29 | import time 30 | 31 | data = [] 32 | RANGE = int(os.environ.get("RANGE", 50000)) 33 | 34 | 35 | def get_random(min, max): 36 | return random.randint(min, max) 37 | 38 | 39 | def math1(x, y): 40 | x * y 41 | 42 | 43 | def math2(x, y): 44 | y**x 45 | 46 | 47 | def do_math(x, y): 48 | math1(x, y) 49 | math2(x, y) 50 | 51 | 52 | def add_mem1(): 53 | data.append("r" * get_random(100, 2000)) 54 | 55 | 56 | def add_mem2(tempdir): 57 | data.append("r" * get_random(100, 2000)) 58 | add_mem3() 59 | name = "".join(random.choice(string.ascii_lowercase) for i in range(10)) 60 | with open(os.path.join(tempdir, name), "w") as f: 61 | f.write("data" * get_random(100, 2000)) 62 | 63 | 64 | def add_mem3(): 65 | data.append("r" * get_random(100, 20000)) 66 | 67 | 68 | # generates one GiB in RSS 69 | def main(): 70 | time.sleep(2) 71 | tempdir = tempfile.mkdtemp() 72 | c = statsd.StatsClient("localhost", 514) 73 | 74 | for i in range(RANGE): 75 | c.incr("cycle") 76 | add_mem1() 77 | add_mem2(tempdir) 78 | 79 | if get_random(1, 20) == 2: 80 | c.incr("random") 81 | 82 | print(f"[{os.getpid()}] Busy adding data! ({i}/{RANGE})") 83 | try: 84 | do_math(i, RANGE) 85 | except Exception as e: 86 | print(e) 87 | 88 | shutil.rmtree(tempdir) 89 | 90 | 91 | if __name__ == "__main__": 92 | main() 93 | print("Bye!") 94 | -------------------------------------------------------------------------------- /perf8/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import os 20 | import shutil 21 | import tempfile 22 | import sys 23 | 24 | from perf8.cli import main 25 | 26 | 27 | def test_main(): 28 | target_dir = tempfile.mkdtemp() 29 | os.environ["RANGE"] = "1000" 30 | 31 | args = [ 32 | "perf8", 33 | "--cprofile", 34 | "--psutil", 35 | "--asyncstats", 36 | "--verbose", 37 | "--refresh-rate=0.1", 38 | "-t", 39 | target_dir, 40 | "-c", 41 | os.path.join(os.path.dirname(__file__), "ademo.py"), 42 | ] 43 | 44 | old_sys = sys.argv 45 | sys.argv = args 46 | try: 47 | main() 48 | assert os.path.exists(os.path.join(target_dir, "index.html")) 49 | finally: 50 | sys.argv = old_sys 51 | shutil.rmtree(target_dir) 52 | -------------------------------------------------------------------------------- /perf8/tests/test_watcher.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import os 20 | import pytest 21 | import shutil 22 | import tempfile 23 | 24 | from perf8.watcher import WatchedProcess 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_watcher(): 29 | os.environ["RANGE"] = "1000" 30 | 31 | class Args: 32 | command = os.path.join(os.path.dirname(__file__), "demo.py") 33 | refresh_rate = 0.1 34 | psutil = True 35 | cprofile = True 36 | memray = False 37 | asyncstats = False 38 | pyspy = False 39 | target_dir = tempfile.mkdtemp() 40 | verbose = 2 41 | psutil_max_rss = 0 42 | max_duration = 0 43 | description = "" 44 | psutil_disk_path = "/tmp" 45 | statsd = True 46 | statsd_port = 514 47 | 48 | try: 49 | watcher = WatchedProcess(Args()) 50 | 51 | await watcher.run() 52 | assert os.path.exists(os.path.join(Args.target_dir, "index.html")) 53 | finally: 54 | shutil.rmtree(Args.target_dir) 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_async_watcher(): 59 | os.environ["RANGE"] = "1000" 60 | 61 | class Args: 62 | command = os.path.join(os.path.dirname(__file__), "ademo.py") 63 | refresh_rate = 0.1 64 | psutil = True 65 | cprofile = False 66 | memray = False 67 | asyncstats = True 68 | pyspy = False 69 | target_dir = tempfile.mkdtemp() 70 | verbose = 2 71 | psutil_max_rss = 0 72 | max_duration = 0 73 | description = "" 74 | psutil_disk_path = "/tmp" 75 | statsd = True 76 | statsd_port = 514 77 | 78 | try: 79 | watcher = WatchedProcess(Args()) 80 | 81 | await watcher.run() 82 | assert os.path.exists(os.path.join(Args.target_dir, "index.html")) 83 | finally: 84 | shutil.rmtree(Args.target_dir) 85 | -------------------------------------------------------------------------------- /perf8/watcher.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import sys 20 | import subprocess 21 | import asyncio 22 | import time 23 | import importlib 24 | import shlex 25 | import signal 26 | import os 27 | from collections import defaultdict 28 | 29 | import humanize 30 | 31 | from perf8.plugins.base import get_registered_plugins 32 | from perf8.reporter import Reporter 33 | from perf8.logger import logger 34 | from perf8.statsd_server import start, StatsdData 35 | 36 | 37 | HERE = os.path.dirname(__file__) 38 | 39 | 40 | class WatchedProcess: 41 | def __init__(self, args): 42 | self.args = args 43 | self.cmd = args.command 44 | if not isinstance(self.cmd, str): 45 | self.cmd = shlex.join(self.cmd) 46 | self.proc = self.pid = None 47 | self.every = args.refresh_rate 48 | self.plugins = [ 49 | plugin for plugin in get_registered_plugins() if getattr(args, plugin.name) 50 | ] 51 | self.out_plugins = [ 52 | plugin(self.args) for plugin in self.plugins if not plugin.in_process 53 | ] 54 | self.out_reports = defaultdict(list) 55 | os.makedirs(self.args.target_dir, exist_ok=True) 56 | signal.signal(signal.SIGINT, self.exit) 57 | signal.signal(signal.SIGTERM, self.exit) 58 | signal.signal(signal.SIGUSR1, self.runner_exit) 59 | self.started = False 60 | self.stats_server = None 61 | self.stats_data = None 62 | 63 | def exit(self, signum, frame): 64 | logger.info(f"We got a {signum} signal, passing it along") 65 | os.kill(self.proc.pid, signum) 66 | 67 | def runner_exit(self, signum, frame): 68 | logger.info(f"We got a {signum} signal, the app finished execution") 69 | logger.info("The app wrapper is now building in-process reports") 70 | # we can stop out of process plugins 71 | self.stop() 72 | 73 | async def _probe(self): 74 | logger.info(f"Starting the probing -- every {self.every} seconds") 75 | 76 | while self.started: 77 | for plugin in self.out_plugins: 78 | if not plugin.enabled: 79 | continue 80 | await plugin.probe(self.pid) 81 | logger.debug(f"Sent a probe to {plugin.name}") 82 | 83 | if self.stats_data is not None: 84 | logger.debug("Flushing statsd") 85 | self.stats_data.flush() 86 | 87 | if self.proc.poll() is not None: 88 | break 89 | 90 | await asyncio.sleep(self.every) 91 | 92 | def start(self): 93 | logger.info(f"[perf8] Plugins: {', '.join([p.name for p in self.plugins])}") 94 | self.started = True 95 | for plugin in self.out_plugins: 96 | plugin.start(self.pid) 97 | if self.args.statsd: 98 | statsd_json = os.path.join(self.args.target_dir, "statsd.json") 99 | self.stats_data = StatsdData(statsd_json) 100 | logger.info(f"Listening to statsd events on port {self.args.statsd_port}") 101 | self.stats_server = asyncio.create_task( 102 | start(self.stats_data, self.args.statsd_port) 103 | ) 104 | 105 | def stop(self): 106 | if not self.started: 107 | return 108 | try: 109 | for plugin in self.out_plugins: 110 | self.out_reports[plugin.name].extend(plugin.stop(self.pid)) 111 | finally: 112 | self.started = False 113 | 114 | async def run(self): 115 | plugins = [plugin.fqn for plugin in self.plugins if plugin.in_process] 116 | 117 | # XXX pass-through perf8 args so the plugins can pick there options 118 | cmd = [ 119 | sys.executable, 120 | "-m", 121 | "perf8.runner", 122 | "-t", 123 | self.args.target_dir, 124 | "--ppid", 125 | str(os.getpid()), 126 | ] 127 | 128 | if len(plugins) > 0: 129 | cmd.extend(["--plugins", ",".join(plugins)]) 130 | 131 | cmd.extend(["-s", self.cmd]) 132 | cmd = [str(item) for item in cmd] 133 | 134 | logger.info(f"[perf8] Running {shlex.join(cmd)}") 135 | start = time.time() 136 | try: 137 | self.proc = subprocess.Popen(cmd) 138 | while self.proc.pid is None: 139 | await asyncio.sleep(1.0) 140 | self.pid = self.proc.pid 141 | self.start() 142 | 143 | await self._probe() 144 | execution_time = time.time() - start 145 | logger.info(f"Command execution time {execution_time:.2f} seconds.") 146 | finally: 147 | if self.stats_server is not None: 148 | await self.stats_server 149 | transport, proto = self.stats_server.result() 150 | transport.close() 151 | self.stats_data.close() 152 | self.stop() 153 | 154 | self.proc.wait() 155 | 156 | execution_info = { 157 | "duration": humanize.precisedelta(execution_time), 158 | "duration_s": execution_time, 159 | } 160 | report_json = os.path.join(self.args.target_dir, "report.json") 161 | reporter = Reporter(self.args, execution_info, self.stats_data) 162 | html_report = reporter.generate(report_json, self.out_reports, self.plugins) 163 | logger.info(f"Find the full report at {html_report}") 164 | if not reporter.success: 165 | logger.info("❌ We have failures") 166 | else: 167 | logger.info("🎉 Looking sharp!") 168 | return reporter.success 169 | 170 | def _plugin_klass(self, fqn): 171 | module_name, klass_name = fqn.split(":") 172 | module = importlib.import_module(module_name) 173 | return getattr(module, klass_name) 174 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psutil==6.0.0 2 | matplotlib>=3.9.0, <4.0.0 3 | flameprof>=0.4, <1.0 4 | memray>=1.9.0, <2.0.0 5 | gprof2dot>=2022.7.29 6 | py-spy>=0.3.14, <1.0.0 7 | importlib-metadata>=6.8.0, <7.0.0 8 | Jinja2>=3.1.4, <4.0.0 9 | humanize>=4.7.0, <5.0.0 10 | statsd>=4.0.1, <5.0.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed to Elasticsearch B.V. under one or more contributor 3 | # license agreements. See the NOTICE file distributed with 4 | # this work for additional information regarding copyright 5 | # ownership. Elasticsearch B.V. licenses this file to you under 6 | # the Apache License, Version 2.0 (the "License"); you may 7 | # not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, 13 | # software distributed under the License is distributed on an 14 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | # KIND, either express or implied. See the License for the 16 | # specific language governing permissions and limitations 17 | # under the License. 18 | # 19 | import sys 20 | import re 21 | from setuptools import setup, find_packages 22 | 23 | if sys.version_info < (3, 9): 24 | raise ValueError("Requires Python 3.9 or superior") 25 | 26 | from perf8 import __version__ # NOQA 27 | 28 | install_requires = [] 29 | with open("requirements.txt") as f: 30 | reqs = f.readlines() 31 | for req in reqs: 32 | req = req.strip() 33 | if req == "" or req.startswith("#"): 34 | continue 35 | install_requires.append(req) 36 | 37 | 38 | description = "" 39 | 40 | for file_ in ("README", "CHANGELOG"): 41 | with open("%s.rst" % file_) as f: 42 | description += f.read() + "\n\n" 43 | 44 | 45 | classifiers = [ 46 | "Programming Language :: Python", 47 | "License :: OSI Approved :: Apache Software License", 48 | "Development Status :: 5 - Production/Stable", 49 | "Programming Language :: Python :: 3 :: Only", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: 3.11" 53 | ] 54 | 55 | 56 | setup( 57 | name="perf8", 58 | version=__version__, 59 | url="https://github.com/elastic/perf8", 60 | packages=find_packages(), 61 | long_description=description.strip(), 62 | description=("Your Tool For Python Performance Tracking"), 63 | author="Ingestion Team", 64 | author_email="tarek@ziade.org", 65 | include_package_data=True, 66 | zip_safe=False, 67 | classifiers=classifiers, 68 | install_requires=install_requires, 69 | entry_points=""" 70 | [console_scripts] 71 | perf8 = perf8.cli:main 72 | """, 73 | ) 74 | -------------------------------------------------------------------------------- /tests-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | black 3 | flake8 4 | pytest-cov 5 | pytest-random-order 6 | pytest-asyncio 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | downloadcache = {toxworkdir}/cache/ 3 | envlist = py310,py39,flake8 4 | 5 | [testenv:py310] 6 | passenv = GITHUB_ACTIONS,GITHUB_TOKEN 7 | deps = -rtests-requirements.txt 8 | -rrequirements.txt 9 | commands = 10 | pytest --random-order-bucket=global -sv --cov-report= --cov-config .coveragerc --cov perf8 perf8/tests 11 | - coverage combine 12 | - coverage report -m 13 | 14 | [testenv] 15 | passenv = GITHUB_ACTIONS,GITHUB_TOKEN 16 | deps = -rtests-requirements.txt 17 | -rrequirements.txt 18 | commands = 19 | pytest --random-order-bucket=global -sv perf8/tests 20 | 21 | [testenv:flake8] 22 | commands = 23 | black perf8 24 | flake8 perf8 25 | 26 | deps = 27 | black 28 | flake8 29 | 30 | [gh-actions] 31 | python = 32 | 3.10: py310, flake8 33 | --------------------------------------------------------------------------------