Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/core_ext/exception_call_stack.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
struct Exception::CallStack
def initialize(*, __callstack @callstack : Array(Void*))
end

{% unless @type.class.has_method?(:decode_backtrace_frame) %} # Crystal < 1.16.0
# :nodoc:
def self.decode_backtrace_frame(ip, show_full_info) : String?
pc = decode_address(ip)
file, line_number, column_number = decode_line_number(pc)

if file && file != "??"
return if @@skip.includes?(file)

# Turn to relative to the current dir, if possible
if current_dir = CURRENT_DIR
if rel = Path[file].relative_to?(current_dir)
rel = rel.to_s
file = rel unless rel.starts_with?("..")
end
end

file_line_column = file
unless line_number == 0
file_line_column = "#{file_line_column}:#{line_number}"
file_line_column = "#{file_line_column}:#{column_number}" unless column_number == 0
end
end

if name = decode_function_name(pc)
function = name
elsif frame = decode_frame(ip)
_, function, file = frame
# Crystal methods (their mangled name) start with `*`, so
# we remove that to have less clutter in the output.
function = function.lchop('*')
else
function = "??"
end

if file_line_column
if show_full_info && (frame = decode_frame(ip))
_, sname, _ = frame
line = "#{file_line_column} in '#{sname}'"
else
line = "#{file_line_column} in '#{function}'"
end
else
if file == "??" && function == "??"
line = "???"
else
line = "#{file} in '#{function}'"
end
end

if show_full_info
line = "#{line} at 0x#{ip.address.to_s(16)}"
end

line
end
{% end %}
end
29 changes: 18 additions & 11 deletions src/perf_tools/common.cr
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require "../core_ext/exception_call_stack"

module PerfTools
protected def self.md_code_span(io : IO, str : String) : Nil
ticks = 0
Expand All @@ -7,6 +9,16 @@ module PerfTools
ticks.times { io << '`' }
end

protected def self.decode_backtrace(stack : Slice(Void*)) : Array(String)
show_full_info = ENV["CRYSTAL_CALLSTACK_FULL_INFO"]? == "1"
frames = [] of String
stack.each do |ip|
frame = Exception::CallStack.decode_backtrace_frame(ip, show_full_info)
frames << frame if frame
end
frames
end

# :nodoc:
# A collection of non-intersecting, sorted intervals representing pointer
# addresses. (The element type must be an integer to avoid false references.)
Expand Down Expand Up @@ -91,11 +103,6 @@ module PerfTools
end
end
end
end

struct Exception::CallStack
def initialize(*, __callstack @callstack : Array(Void*))
end

{% if flag?(:win32) %}
{% if flag?(:interpreted) %} @[Primitive(:interpreter_call_stack_unwind)] {% end %}
Expand All @@ -104,10 +111,10 @@ struct Exception::CallStack
context = Pointer(LibC::CONTEXT).malloc(1)
context.value.contextFlags = LibC::CONTEXT_FULL
LibC.RtlCaptureContext(context)

# unlike DWARF, this is required on Windows to even be able to produce
# correct stack traces, so we do it here but not in `libunwind.cr`
load_debug_info
Exception::CallStack.load_debug_info

machine_type = {% if flag?(:x86_64) %}
LibC::IMAGE_FILE_MACHINE_AMD64
Expand Down Expand Up @@ -159,10 +166,10 @@ struct Exception::CallStack
data.as({Void**, Void**}*).value = {b + 1, e}

ip = {% if flag?(:arm) %}
Pointer(Void).new(__crystal_unwind_get_ip(context))
{% else %}
Pointer(Void).new(LibUnwind.get_ip(context))
{% end %}
Pointer(Void).new(__crystal_unwind_get_ip(context))
{% else %}
Pointer(Void).new(LibUnwind.get_ip(context))
{% end %}
b.value = ip

{% if flag?(:gnu) && flag?(:i386) %}
Expand Down
185 changes: 82 additions & 103 deletions src/perf_tools/fiber_trace.cr
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@ require "./common"

# In-memory tracking of all existing fibers in the running program.
module PerfTools::FiberTrace
# :nodoc:
class_getter spawn_stack = {} of Fiber => Array(Void*)

# :nodoc:
class_getter yield_stack = {} of Fiber => Array(Void*)

# :nodoc:
class_getter lock = Thread::Mutex.new

{% begin %}
# The maximum number of stack frames shown for `FiberTrace.log_fibers` and
# `FiberTrace.pretty_log_fibers`.
Expand Down Expand Up @@ -96,26 +87,22 @@ module PerfTools::FiberTrace
# src/perf_tools/fiber_trace.cr:184:3 in 'run'
# /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/fiber.cr:98:34 in '->'
# ```
#
# NOTE: The main fiber of each thread is not shown.
def self.log_fibers(io : IO) : Nil
lock.synchronize do
io << spawn_stack.size << '\n'
spawn_stack.each do |fiber, stack|
io << fiber.name << '\n'
fibers = [] of Fiber
Fiber.each { |fiber| fibers << fiber }

s = Exception::CallStack.new(__callstack: stack).printable_backtrace
io << s.size << '\n'
s.each { |frame| io << frame << '\n' }
io << fibers.size << '\n'

if yield_stack = self.yield_stack[fiber]?
y = Exception::CallStack.new(__callstack: yield_stack).printable_backtrace
io << y.size << '\n'
y.each { |frame| io << frame << '\n' }
else
io << '0' << '\n'
end
end
fibers.each do |fiber|
io << fiber.name << '\n'

s = PerfTools.decode_backtrace(fiber.__spawn_stack)
io << s.size << '\n'
s.each { |frame| io << frame << '\n' }

y = PerfTools.decode_backtrace(fiber.__yield_stack)
io << y.size << '\n'
y.each { |frame| io << frame << '\n' }
end
end

Expand All @@ -142,114 +129,106 @@ module PerfTools::FiberTrace
# | 1 | | ` test.cr:4:1 in '__crystal_main' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/crystal/main.cr:129:5 in 'main_user_code' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/crystal/main.cr:115:7 in 'main' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/crystal/main.cr:141:3 in 'main' ` | ` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/crystal/scheduler.cr:50:5 in 'reschedule' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/concurrent.cr:29:3 in 'sleep' `<br>` test.cr:3:9 in '->' `<br>` /Users/quinton/crystal/perf-tools/src/perf_tools/fiber_trace.cr:172:3 in 'run' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/fiber.cr:98:34 in '->' ` |
# | 1 | ` Signal Loop ` | ` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/crystal/system/unix/signal.cr:60:5 in 'start_loop' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/crystal/system/unix/signal.cr:163:5 in 'setup_default_handlers' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/kernel.cr:558:1 in '__crystal_main' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/crystal/main.cr:129:5 in 'main_user_code' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/crystal/main.cr:115:7 in 'main' ` | ` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/crystal/scheduler.cr:50:5 in 'reschedule' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/io/evented.cr:128:5 in 'wait_readable' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/io/evented.cr:119:3 in 'wait_readable' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/io/evented.cr:59:9 in 'unbuffered_read' `<br>` /opt/homebrew/Cellar/crystal/1.9.2/share/crystal/src/io/buffered.cr:261:5 in 'fill_buffer' ` |
# ```
#
# NOTE: The main fiber of each thread is not shown.
def self.pretty_log_fibers(io : IO) : Nil
lock.synchronize do
uniqs = spawn_stack
.map { |fiber, stack| {fiber.name, stack, yield_stack[fiber]?} }
.group_by { |_, s, y| {s, y} }
.transform_values(&.map { |fiber, _, _| fiber })
.to_a
.sort_by! { |(s, y), names| {-names.size, s, y || Array(Void*).new} }

io.puts "| Count | Fibers | Spawn stack | Yield stack |"
io.puts "|------:|:-------|:------------|:------------|"
uniqs.each do |(s, y), names|
s = Exception::CallStack.new(__callstack: s).printable_backtrace
y = y.try { |y| Exception::CallStack.new(__callstack: y).printable_backtrace }

io << "| "
io << names.size
io << " | "
names.compact.join(io, ' ') { |name| PerfTools.md_code_span(io, name) }
io << " | "
s.join(io, "<br>") { |frame| PerfTools.md_code_span(io, frame) }
io << " | "
if y
y.join(io, "<br>") { |frame| PerfTools.md_code_span(io, frame) }
else
io << "*N/A*"
fibers = [] of Fiber
Fiber.each { |fiber| fibers << fiber }

uniqs = fibers
.map { |fiber| {fiber.name, fiber.__spawn_stack, fiber.__yield_stack} }
.group_by { |_, s, y| {s, y} }
.transform_values(&.map { |fiber, _, _| fiber })
.to_a
.sort_by! { |(s, y), names| {-names.size, s, y} }

io.puts "| Count | Fibers | Spawn stack | Yield stack |"
io.puts "|------:|:-------|:------------|:------------|"
uniqs.each do |(s, y), names|
io << "| "
io << names.size
io << " | "
names.compact.join(io, ' ') { |name| PerfTools.md_code_span(io, name) }
io << " | "
PerfTools.decode_backtrace(s).join(io, "<br>") do |frame|
PerfTools.md_code_span(io, frame)
end
io << " | "
if y.size > 0
PerfTools.decode_backtrace(y).join(io, "<br>") do |frame|
PerfTools.md_code_span(io, frame)
end
io << " |\n"
else
io << "*N/A*"
end
io << " |\n"
end
end

# :nodoc:
macro track_fiber(action, current_fiber)
%stack = Array.new(PerfTools::FiberTrace::STACK_DEPTH + PerfTools::FiberTrace::STACK_SKIP_{{action.upcase.id}}, Pointer(Void).null)
Exception::CallStack.unwind_to(Slice.new(%stack.to_unsafe, %stack.size))
%stack.truncate(PerfTools::FiberTrace::STACK_SKIP_{{action.upcase.id}}..)
while %stack.last? == Pointer(Void).null
%stack.pop
def self.caller_stack(skip, depth = PerfTools::FiberTrace::STACK_DEPTH)
size = skip + depth

stack = Array(Void*).new(size, Pointer(Void).null)
slice = Slice(Void*).new(stack.to_unsafe, size)
PerfTools.unwind_to(slice)

while stack.last?.try(&.null?)
stack.pop
end
PerfTools::FiberTrace.lock.synchronize do
PerfTools::FiberTrace.{{action.id}}_stack[{{current_fiber}}] = %stack

stack
end

# :nodoc:
#
# returns a slice view into the stack array, avoids allocating a new array,
# returns an empty slice if array is nil.
def self.stack_array_to_slice(stack, skip)
if stack
skip = PerfTools::FiberTrace::STACK_SKIP_YIELD
Slice.new(stack.to_unsafe + skip, stack.size - skip)
else
Slice.new(Pointer(Void*).null, 0)
end
end
end

class Fiber
# the stacks could be saved as slices instead of arrays but then we'd have two
# writes to write the slice pointer and the slice size instead of a single
# pointer write for the array (the buffer pointer and size are on the array
# object); we could always allocate slices of DEPTH+SKIP size but we hit
# recursion issues with crystal/once (fiber.current -> access constant ->
# crystal/once -> fiber.current)
@__spawn_stack : Array(Void*)?
@__yield_stack : Array(Void*)?

{% begin %}
def initialize(
name : String?,
{% if Fiber.has_constant?(:Stack) %}stack : Stack,{% end %}
{% if flag?(:execution_context) %}execution_context : ExecutionContext = ExecutionContext.current,{% end %}
&proc : ->
)
@__spawn_stack = PerfTools::FiberTrace.caller_stack(PerfTools::FiberTrace::STACK_SKIP_SPAWN)
previous_def(
name,
{% if Fiber.has_constant?(:Stack) %}stack,{% end %}
{% if flag?(:execution_context) %}execution_context,{% end %}
&proc
)
PerfTools::FiberTrace.track_fiber(:spawn, self)
end
{% end %}

def self.inactive(fiber : Fiber)
PerfTools::FiberTrace.lock.synchronize do
PerfTools::FiberTrace.spawn_stack.delete(fiber)
PerfTools::FiberTrace.yield_stack.delete(fiber)
end
previous_def
def __spawn_stack : Slice(Void*)
PerfTools::FiberTrace.stack_array_to_slice(@__spawn_stack, PerfTools::FiberTrace::STACK_SKIP_SPAWN)
end

# crystal-lang/crystal#13701
{% unless Fiber.class.has_method?(:inactive) %} # Crystal < 1.10.0
def run
GC.unlock_read
@proc.call
rescue ex
if name = @name
STDERR.print "Unhandled exception in spawn(name: #{name}): "
else
STDERR.print "Unhandled exception in spawn: "
end
ex.inspect_with_backtrace(STDERR)
STDERR.flush
ensure
{% if flag?(:preview_mt) %}
Crystal::Scheduler.enqueue_free_stack @stack
{% elsif flag?(:interpreted) %}
# For interpreted mode we don't need a new stack, the stack is held by the interpreter
{% else %}
Fiber.stack_pool.release(@stack)
{% end %}

# Remove the current fiber from the linked list
Fiber.inactive(self)

# Delete the resume event if it was used by `yield` or `sleep`
@resume_event.try &.free
@timeout_event.try &.free
@timeout_select_action = nil
def __yield_stack : Slice(Void*)
PerfTools::FiberTrace.stack_array_to_slice(@__yield_stack, PerfTools::FiberTrace::STACK_SKIP_YIELD)
end

@alive = false
Crystal::Scheduler.reschedule
end
{% end %}
def __yield_stack=(@__yield_stack)
end
end

class Crystal::Scheduler
Expand All @@ -260,7 +239,7 @@ class Crystal::Scheduler
{% else %}
@current
{% end %}
PerfTools::FiberTrace.track_fiber(:yield, current_fiber)
current_fiber.__yield_stack = PerfTools::FiberTrace.caller_stack(PerfTools::FiberTrace::STACK_SKIP_YIELD)
previous_def
end
end
2 changes: 1 addition & 1 deletion src/perf_tools/mem_prof.cr
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ module PerfTools::MemProf
type_id, @@last_type_id = @@last_type_id, 0

stack = StaticArray(Void*, STACK_TOTAL).new(Pointer(Void).null)
Exception::CallStack.unwind_to(stack.to_slice)
PerfTools.unwind_to(stack.to_slice)
key = StaticArray(UInt64, STACK_DEPTH).new { |i| stack.unsafe_fetch(STACK_SKIP &+ i).address }
alloc_infos[ptr.address] = AllocInfo.new(size, key, type_id, atomic)
unless type_id == 0
Expand Down