Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Export jemalloc stats to prometheus when used #9882

Merged
merged 9 commits into from
May 6, 2021
1 change: 1 addition & 0 deletions changelog.d/9882.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Export jemalloc stats to Prometheus if it is being used.
2 changes: 2 additions & 0 deletions synapse/app/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from synapse.config.homeserver import HomeServerConfig
from synapse.crypto import context_factory
from synapse.logging.context import PreserveLoggingContext
from synapse.metrics.jemalloc import setup_jemalloc_stats
from synapse.metrics.background_process_metrics import wrap_as_background_process
from synapse.util.async_helpers import Linearizer
from synapse.util.daemonize import daemonize_process
Expand Down Expand Up @@ -115,6 +116,7 @@ def start_reactor(

def run():
logger.info("Running")
setup_jemalloc_stats()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it to get called here, mainly so that we try to set it up after we've got logging set up.

change_resource_limit(soft_file_limit)
if gc_thresholds:
gc.set_threshold(*gc_thresholds)
Expand Down
1 change: 1 addition & 0 deletions synapse/metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ def f(*args, **kwargs):
except AttributeError:
pass


__all__ = [
"MetricsResource",
"generate_latest",
Expand Down
191 changes: 191 additions & 0 deletions synapse/metrics/jemalloc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# Copyright 2021 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import ctypes
import logging
import os
import re
from typing import Optional

from synapse.metrics import REGISTRY, GaugeMetricFamily

logger = logging.getLogger(__name__)


def _setup_jemalloc_stats():
"""Checks to see if jemalloc is loaded, and hooks up a collector to record
statistics exposed by jemalloc.
"""

# Try to find the loaded jemalloc shared library, if any. We need to
# introspect into what is loaded, rather than loading whatever is on the
# path, as if we load a *different* jemalloc version things will seg fault.

# We look in `/proc/self/maps`, which only exists on linux.
if not os.path.exists("/proc/self/maps"):
logger.debug("Not looking for jemalloc as no /proc/self/maps exist")
return

# We're looking for a path at the end of the line that includes
# "libjemalloc".
regex = re.compile(r"/\S+/libjemalloc.*$")

jemalloc_path = None
with open("/proc/self/maps") as f:
for line in f:
match = regex.search(line.strip())
if match:
jemalloc_path = match.group()

if not jemalloc_path:
# No loaded jemalloc was found.
logger.debug("jemalloc not found")
return

jemalloc = ctypes.CDLL(jemalloc_path)

def _mallctl(
name: str, read: bool = True, write: Optional[int] = None
) -> Optional[int]:
"""Wrapper around `mallctl` for reading and writing integers to
jemalloc.

Args:
name: The name of the option to read from/write to.
read: Whether to try and read the value.
write: The value to write, if given.

Returns:
The value read if `read` is True, otherwise None.

Raises:
An exception if `mallctl` returns a non-zero error code.
"""

input_var = None
input_var_ref = None
input_len_ref = None
if read:
input_var = ctypes.c_size_t(0)
input_len = ctypes.c_size_t(ctypes.sizeof(input_var))

input_var_ref = ctypes.byref(input_var)
input_len_ref = ctypes.byref(input_len)

write_var_ref = None
write_len = ctypes.c_size_t(0)
if write is not None:
write_var = ctypes.c_size_t(write)
write_len = ctypes.c_size_t(ctypes.sizeof(write_var))

write_var_ref = ctypes.byref(write_var)

# The interface is:
#
# int mallctl(
# const char *name,
# void *oldp,
# size_t *oldlenp,
# void *newp,
# size_t newlen
# )
#
# Where oldp/oldlenp is a buffer where the old value will be written to
# (if not null), and newp/newlen is the buffer with the new value to set
# (if not null). Note that they're all references *except* newlen.
result = jemalloc.mallctl(
name.encode("ascii"),
input_var_ref,
input_len_ref,
write_var_ref,
write_len,
)

if result != 0:
raise Exception("Failed to call mallctl")

if input_var is None:
return None

return input_var.value

def _jemalloc_refresh_stats() -> None:
"""Request that jemalloc updates its internal statistics. This needs to
be called before querying for stats, otherwise it will return stale
values.
"""
try:
_mallctl("epoch", read=False, write=1)
except Exception:
pass
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still feel like we shouldn't be completely dropping these exceptions. Why not log something at warn?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, github obviously marked this as outdated. Will fix.


class JemallocCollector:
"""Metrics for internal jemalloc stats."""

def collect(self):
_jemalloc_refresh_stats()

g = GaugeMetricFamily(
"jemalloc_stats_app_memory_bytes",
"The stats reported by jemalloc",
labels=["type"],
)

# Read the relevant global stats from jemalloc. Note that these may
# not be accurate if python is configured to use its internal small
# object allocator (which is on by default, disable by setting the
# env `PYTHONMALLOC=malloc`).
#
# See the jemalloc manpage for details about what each value means,
# roughly:
# - allocated ─ Total number of bytes allocated by the app
# - active ─ Total number of bytes in active pages allocated by
# the application, this is bigger than `allocated`.
# - resident ─ Maximum number of bytes in physically resident data
# pages mapped by the allocator, comprising all pages dedicated
# to allocator metadata, pages backing active allocations, and
# unused dirty pages. This is bigger than `active`.
# - mapped ─ Total number of bytes in active extents mapped by the
# allocator.
# - metadata ─ Total number of bytes dedicated to jemalloc
# metadata.
for t in (
"allocated",
"active",
"resident",
"mapped",
"metadata",
):
try:
value = _mallctl(f"stats.{t}")
except Exception:
# There was an error fetching the value, skip.
continue

g.add_metric([t], value=value)

yield g

REGISTRY.register(JemallocCollector())

logger.debug("Added jemalloc stats")


def setup_jemalloc_stats():
"""Try to setup jemalloc stats, if jemalloc is loaded."""

try:
_setup_jemalloc_stats()
except Exception as e:
logger.info("Failed to setup collector to record jemalloc stats: %s", e)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when do you expect this to be hit?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can happen if we choose the wrong jemalloc library mainly, though that shouldn't really happen if we're looking in /proc/self/maps