Skip to content

Commit

Permalink
Add support for Ruby (#12)
Browse files Browse the repository at this point in the history
Co-authored-by: Donald Lee <[email protected]>
  • Loading branch information
s7nfo and dlqs authored Jul 7, 2024
1 parent 5f487c6 commit ad66700
Show file tree
Hide file tree
Showing 18 changed files with 667 additions and 62 deletions.
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
pull_request:

jobs:
build:

runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v2

- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.11'

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'

- name: Run tests
run: make test
19 changes: 19 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
__pycache__/
*.py[cod]
*$py.class

*.so

*.egg
*.egg-info/
dist/
build/
*.pyd

.DS_Store
Gemfile.lock

python/cirron/cirronlib.cpp
python/cirron/apple_arm_events.h
ruby/lib/cirronlib.cpp
ruby/lib/apple_arm_events.h
38 changes: 38 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
SOURCES = src/cirronlib.cpp src/apple_arm_events.h
PYTHON_DIR = python/cirron
RUBY_DIR = ruby/lib

.PHONY: test build build-python build-ruby copy-sources

test: test-python test-ruby

copy-sources:
cp $(SOURCES) $(PYTHON_DIR)/
cp $(SOURCES) $(RUBY_DIR)/

.PHONY: test-python
test-python: copy-sources
rm -f $(PYTHON_DIR)/cirronlib.so
@echo "Running Python tests..."
sudo PYTHONPATH=./python python -m unittest discover -s python/tests

.PHONY: test-ruby
test-ruby: copy-sources
rm -f $(RUBY_DIR)/cirronlib.so
@echo "Running Ruby tests..."
cd ruby && \
sudo gem install bundler && \
sudo bundle install && \
sudo bundle exec ruby -Ilib:test tests/tests.rb

build: build-python build-ruby

.PHONY: build-python
build-python: copy-sources
@echo "Building Python package..."
cd python && python setup.py sdist bdist_wheel

.PHONY: build-ruby
build-ruby: copy-sources
@echo "Building Ruby package..."
cd ruby && gem build cirron.gemspec
84 changes: 65 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,95 @@
# Cirron

Cirron measures a piece of Python code and report back several performance counters: CPU instruction count, branch misses, page faults and time spent measuring. It uses the Linux perf events interface or @ibireme's [KPC demo](https://gist.github.com/ibireme/173517c208c7dc333ba962c1f0d67d12) on OSX.
Cirron measures a piece of Python or Ruby code and report back several performance counters: CPU instruction count, branch misses, page faults and time spent measuring. It uses the Linux perf events interface or @ibireme's [KPC demo](https://gist.github.com/ibireme/173517c208c7dc333ba962c1f0d67d12) on OSX.

It can also trace syscalls using `strace`, Linux only!

## Prerequisites

- Linux with perf events support / Apple ARM OSX
- C++
- Python 3.x
- Python 3.x / Ruby 3.x

## Installation

### Python

```bash
pip install cirron
```

The Python wrapper automatically compiles the C++ library (cirronlib.cpp) on first use.
### Ruby

```bash
gem install cirron
```

The wrapper automatically compiles the C++ library (cirronlib.cpp) on first use.

## Usage

### Performance Counters

#### Python

```
$ sudo python
>>> from cirron import Collector
>>>
>>> # Start collecting performance metrics
>>> with Collector() as collector:
>>> # Your code here
>>> print("Hello")
>>>
>>> # Retrieve the metrics
>>> print(collector.counters)
Counter(time_enabled_ns=144185, instruction_count=19434, branch_misses=440, page_faults=0)
```
from cirron import Collector

# Start collecting performance metrics
with Collector() as collector:
# Your code here
# ...
#### Ruby

# Retrieve the metrics
print(collector.counters)
```
$ sudo irb
irb(main):001> require 'cirron'
=> true
irb(main):002* c = Cirron::collector do
irb(main):003* puts "Hello"
irb(main):004> end
Hello
=> Counter(time_enabled_ns: 110260, instruction_count: 15406, branch_misses: 525, page_faults: 0)
```

### Syscalls
```
from cirron import Tracer, to_tef

with Tracer() as tracer:
# Your code here
# ...
#### Python

# Stop collecting and retrieve the trace
print(tracer.trace)
```
$ sudo python
>>> from cirron import Tracer, to_tef
# Save the trace for ingesting to Perfetto
open("/tmp/trace", "w").write(to_tef(trace))
>>> with Tracer() as tracer:
>>> # Your code here
>>> print("Hello")
>>>
>>> # Retrieve the trace
>>> print(tracer.trace)
>>> [Syscall(name='write', args='1, "Hello\\n", 6', retval='6', duration='0.000043', timestamp='1720333364.368337', pid='2270837')]
>>>
>>> # Save the trace for ingesting to Perfetto
>>> open("/tmp/trace", "w").write(to_tef(tracer.trace))
```
#### Ruby

```
$ sudo irb
irb> require 'cirron'
=> true
irb> trace = Cirron::tracer do
irb> # Your code here
irb> puts "Hello"
irb> end
=> [#<Syscall:0x00007c6c1a4b3608 @args="1, [{iov_base=\"Hello\", iov_len=5}, {iov_base=\"\\n\", iov_len=1}], 2", @duration="0.000201", @name="writev", @pid="2261962", @retval="6", @timestamp="1720285300.334976">]
# Save the trace for ingesting to Perfetto
irb> File.write("/tmp/trace", Cirron::to_tef(trace))
=> 267
```
File renamed without changes.
48 changes: 29 additions & 19 deletions cirron/cirron.py → python/cirron/cirron.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,43 @@ def __repr__(self):
)


overhead = {}


class Collector:
cirron_lib = CDLL(lib_path)
cirron_lib.start.argtypes = None
cirron_lib.start.restype = c_int
cirron_lib.end.argtypes = [c_int, POINTER(Counter)]
cirron_lib.end.restype = None

def __init__(self):
def __init__(self, measure_overhead=True):
self._fd = None
self.counters = Counter()
self.measure_overhead = measure_overhead

# We try to estimate what the overhead of the collector is, taking the minimum
# of 10 runs.
global overhead
if measure_overhead and not overhead:
for _ in range(10):
with Collector(measure_overhead=False) as collector:
pass

for field, _ in Counter._fields_:
if field not in overhead:
overhead[field] = getattr(collector.counters, field)
else:
overhead[field] = min(
overhead[field], getattr(collector.counters, field)
)

def __enter__(self):
ret_val = Collector.cirron_lib.start()
if ret_val == -1:
raise Exception("Failed to start collector. Make sure you have the right permissions, you might need to use sudo.")
raise Exception(
"Failed to start collector. Make sure you have the right permissions, you might need to use sudo."
)
self._fd = ret_val

return self
Expand All @@ -69,7 +91,7 @@ def __exit__(self, exc_type, exc_value, traceback):
raise Exception("Failed to end collector.")

global overhead
if overhead:
if self.measure_overhead and overhead:
for field, _ in Counter._fields_:
# Clamp the result of overhead substraction to 0.
if getattr(self.counters, field) > overhead[field]:
Expand All @@ -81,20 +103,8 @@ def __exit__(self, exc_type, exc_value, traceback):
else:
setattr(self.counters, field, 0)

def __str__(self):
return self.__repr__()

# We try to estimate what the overhead of the collector is, taking the minimum
# of 10 runs.
overhead = {}
collector = Collector()
o = {}
for _ in range(10):
with Collector() as collector:
pass

for field, _ in Counter._fields_:
if field not in overhead:
o[field] = getattr(collector.counters, field)
else:
o[field] = min(overhead[field], getattr(collector.counters, field))
overhead = o
del collector
def __repr__(self):
return repr(self.counters)
60 changes: 54 additions & 6 deletions cirron/tracer.py → python/cirron/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import subprocess
import re
import json
import time
from dataclasses import dataclass


Expand Down Expand Up @@ -66,13 +67,15 @@ def __str__(self):


def parse_strace(f):
syscall_pattern = re.compile(r"^(\d+) (\d+\.\d+) (\w+)\((.*?)\) += +(.*?) <(.*?)>$")
signal_pattern = re.compile(r"^(\d+) (\d+\.\d+) --- (\w+) {(.*)} ---$")
syscall_pattern = re.compile(
r"^(\d+) +(\d+\.\d+) (\w+)\((.*?)\) += +(.*?) <(.*?)>$"
)
signal_pattern = re.compile(r"^(\d+) +(\d+\.\d+) --- (\w+) {(.*)} ---$")
unfinished_pattern = re.compile(
r"^(\d+) (\d+\.\d+) (\w+)\((.*?) +<unfinished \.\.\.>$"
r"^(\d+) +(\d+\.\d+) (\w+)\((.*?) +<unfinished \.\.\.>$"
)
resumed_pattern = re.compile(
r"^(\d+) (\d+\.\d+) <\.\.\. (\w+) resumed>(.*?)?\) += +(.*?) <(.*?)>$"
r"^(\d+) +(\d+\.\d+) <\.\.\. (\w+) resumed>(.*?)?\) += +(.*?) <(.*?)>$"
)

result = []
Expand Down Expand Up @@ -130,24 +133,69 @@ def parse_strace(f):
return result


def filter_trace(trace, marker_path):
start_index = next(
(i for i, r in enumerate(trace) if marker_path in getattr(r, "args", "")), None
)
end_index = next(
(
i
for i in range(len(trace) - 1, -1, -1)
if marker_path in getattr(trace[i], "args", "")
),
None,
)

if start_index is not None and end_index is not None:
return trace[start_index + 1 : end_index]
else:
print(
"Failed to find start and end markers for the trace, returning the full trace."
)
return trace


class Tracer:
def __enter__(self):
def __enter__(self, timeout=10):
parent_pid = os.getpid()
self._trace_file = tempfile.mktemp()

cmd = f"strace --quiet=attach,exit -f -T -ttt -o {self._trace_file} -p {parent_pid}".split()
self._strace_proc = subprocess.Popen(cmd)

# Wait for the trace file to be created
deadline = time.monotonic() + timeout
while not os.path.exists(self._trace_file):
if time.monotonic() > deadline:
raise TimeoutError(f"Failed to start strace within {timeout}s.")
# :(
time.sleep(0.1)

try:
# We use this dummy fstat to recognize when we start executing the block
os.stat(self._trace_file + ".dummy")
except:
pass

return self

def __exit__(self, exc_type, exc_value, traceback):
try:
# Same here, to recognize when we're done executing the block
os.stat(self._trace_file + ".dummy")
except:
pass

self._strace_proc.terminate()
self._strace_proc.wait()

with open(self._trace_file, "r") as f:
self.trace = parse_strace(f)
self.trace = filter_trace(parse_strace(f), self._trace_file + ".dummy")

os.unlink(self._trace_file)

def __str__(self):
return self.__repr__()

def __repr__(self):
return repr(self.trace)
Loading

0 comments on commit ad66700

Please sign in to comment.