Packages

A small library for estimating the memory usage of huge process states.

Current section

Files

Jump to
memory_size lib memory_size.ex
Raw

lib/memory_size.ex

defmodule MemorySize do
@moduledoc """
This module provides two utilities for understanding what is using the memory in a complex data
structure. summary/2 provides information about what fields are using data in a struct tree,
while term/2 aims to be a fast, approximate replacement for :erts_debug.flat_size.
"""
@default_sample_size 100
@doc """
Returns a list of tuples of the form {size, unit, path} summarizing the memory
usage of the passed in value (if it's a struct, one tuple per recursive field
will be returned. i.e. if state.guild.sessions is using 100 words, then one of
the returned tuples would be {100, :word, [:guild, :sessions]}.
Options available:
- max_depth: maximum nesting level for returned tuples (0 means "just the top level", default is unlimited)
- min_size: a threshold for dropping fields from the result that use less than some threshold
amount of memory (default is to show all)
- precision: How many digits should we keep after the decimal point (default is 2)
- sample_size: control how many elements of large maps/lists will be sampled (default is 100)
- sort_by_size: if true, will sort the results by size descending (otherwise will be sorted hierarchically)
- unit: one of :word, :byte, :kilobyte, :megabyte, :gigabyte for scaling the results (default is word).
Kilo means 1024 (in order to be consistent with `:recon_alloc.set_unit`)
"""
def summary(value, options \\ []) do
sample_size = Keyword.get(options, :sample_size, @default_sample_size)
min_size = Keyword.get(options, :min_size, -1)
max_depth = Keyword.get(options, :max_depth, :infinity)
precision = Keyword.get(options, :precision, 2)
unit = Keyword.get(options, :unit, :word)
unit_scale = scale(unit)
sort_mapper =
if Keyword.get(options, :sort_by_size, false) do
fn {size, _, path} -> {-size, path} end
else
fn {_, _, path} -> path end
end
do_size_summary(value, sample_size, [])
|> Enum.map(fn {size, reverse_path} ->
{Float.round(size / unit_scale, precision), unit, Enum.reverse(reverse_path)}
end)
|> Enum.filter(fn {size, _, _} -> size >= min_size end)
|> Enum.filter(fn {_, _, path} -> length(path) <= max_depth end)
|> Enum.sort_by(sort_mapper)
end
@doc """
This estimates the size of an Erlang term. It intends to take a reasonable amount of time
to complete, as opposed to :erts_debug.flat_size and :erts_debug.size. it does this by
sampling large maps and lists. Note that it follows the convention of :erts_debug.size and
does not include the 1 word for the term itself. This means that some of the calculations
here will seem to be off from https://www.erlang.org/doc/efficiency_guide/advanced.html
which includes this 1 word in the estimates it provides.
"""
def term(value, sample_size \\ @default_sample_size)
def term(value, sample_size) when is_list(value) do
# A list takes 2 words (head and tail) per entry + the heap space needed for the contents
2 * length(value) + raw_list_size(value, sample_size)
end
def term(value, sample_size) when is_map(value) do
map_overhead(value) + raw_map_size(value, sample_size)
end
def term(value, sample_size) when is_tuple(value) do
# A tuple takes 1 word for the count + a word for each entry
list = Tuple.to_list(value)
1 + length(list) + raw_list_size(list, sample_size)
end
def term(value, _sample_size) when is_bitstring(value) do
# This is probably wrong. I don't know what the right answer is. :erts_debug.size seems
# to always say 6 words regardless of size (except empty string is 2 words). It's definitely
# going to need 1 word for each 8 bytes when you get down to it though.
max(:erts_debug.size(value), byte_size(value) / 8 + 1)
end
def term(value, _sample_size) do
:erts_debug.size(value)
end
# Calculates the sum of the heap space needed for the values of list. This does not include
# the space needed for the list itself.
defp raw_list_size([], _sample_size) do
0
end
defp raw_list_size(list, sample_size) do
value_sample = Enum.take_random(list, sample_size)
value_sample_size = Enum.reduce(value_sample, 0, &(term(&1, sample_size) + &2))
# scale the sampled values to the entire list
value_sample_size * length(list) / length(value_sample)
end
defp raw_map_size(map, _sample_size) when map_size(map) == 0 do
0
end
defp raw_map_size(%{__struct__: _} = map, sample_size) do
# The "raw" size of the struct key and value are both 0 (they're both atoms so use 0 words
# extra above the term itself)
raw_map_size(Map.from_struct(map), sample_size)
end
defp raw_map_size(map, sample_size) do
value_sample_size =
Enum.take_random(map, sample_size)
|> Enum.reduce(0, fn
nil, acc -> acc
{key, value}, acc -> term(key, sample_size) + term(value, sample_size) + acc
end)
map_size = map_size(map)
value_sample_length = min(map_size, sample_size)
value_sample_size * map_size / value_sample_length
end
defp map_overhead(map) do
# according to the efficiency guide, it's 4 words of overhead up to 32 elements, then
# beyond that it's somewhere between 1.6 - 1.8 words of overhead per element, plus a
# word each for the key and value itself, so we use 3.7 as an estimate of that overhead.
# For <= 32 elements, there are 4 words of overhead + the same word each for the key and
# value.
if map_size(map) > 32 do
3.7 * map_size(map)
else
4 + 2 * map_size(map)
end
end
defp do_size_summary(%MapSet{} = value, sample_size, path) do
# Yeah it's seriously 10 words of overhead (4 for the struct map overhead, 2 each for the
# key-value pairs in the struct (:__struct__, :map, and :version)
[{term(value.map, sample_size) + 10, path}]
end
defp do_size_summary(value, sample_size, path) when is_struct(value) do
{fields_sum, fields_summary} =
Enum.reduce(Map.from_struct(value), {0, []}, fn {key, field_value},
{acc_sum, acc_summary} ->
# The child's overall sum entry is the head of its size summary
[{field_sum, _} | _] =
field_summary = do_size_summary(field_value, sample_size, [key | path])
{acc_sum + field_sum, field_summary ++ acc_summary}
end)
[{fields_sum + map_overhead(value), path} | fields_summary]
end
defp do_size_summary(value, sample_size, path) do
[{term(value, sample_size), path}]
end
defp scale(:word), do: 1
defp scale(:byte), do: 1 / 8
defp scale(:kilobyte), do: 1024 / 8
defp scale(:megabyte), do: 1024 * 1024 / 8
defp scale(:gigabyte), do: 1024 * 1024 * 1024 / 8
end