microsoft / microsoft/mimalloc

`malloc_normal.current` / `malloc_huge.current` drifts when a thread rarely allocates and frees other threads' memory periodically

Open
#1,410 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
C
Stars
13.4k
Forks
1.2k
Avg merge
4d 45m
Merged PRs (30d)
13

Description

A thread that rarely allocates and often frees memory allocated by other threads subtracts the freed bytes only from its own (theap) stats.

Allocation statistics are kept per thread and are merged into the shared stats only when that thread collects (mi_collect()) or exits. A thread merges its stats only after every generic_collect allocations, never on a free. A thread that does not allocate enough doesn’t get its stats merged. mi_stats_get() therefore over-counts by everything that thread has freed since its last collect, and becomes correct again only when the thread collects or exits.

We first noticed this issue in v3.4.3. v3.5.2 with the new per-page statistics partially fixes it. A thread that has never allocated has no theap, so its frees go directly into the shared stats.

We are running services with dedicated logging threads. Worker threads write events into a queue, the logging thread reads the events, deallocates some data attached to the events and flushes the log. We compute "currently allocated bytes" of the process as mi_stats_t.malloc_normal.current + mi_stats_t.malloc_huge.current

With the logger enabled the value drifts upward. We tried comparing this with tcmalloc. With the logger disabled it behaves as expected and matches tcmalloc's generic.current_allocated_bytes on the same workload.

The logging thread generally doesn’t allocate, so its stats are never collected. Lowering generic_collect doesn’t help. This was tested on v3.4.3 but is reproducible on v3.5.2 as well.

Logger on (green: mimalloc, yellow: tcmalloc, y-axis in GB):

Image

Logger off (green: mimalloc, yellow: tcmalloc, y-axis in GB):

Image

Reproduction:

// cc -O2 -pthread repro.c -lmimalloc
// (default build, MI_STATS=1)
#include <mimalloc.h>
#include <mimalloc-stats.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdatomic.h>
#include <unistd.h>

mi_stats_t collect_stats() {
  mi_stats_t s;
  mi_stats_init(&s);
  mi_stats_get(&s);
  return s;
}

size_t collect_rss() {
  size_t rss = 0;
  mi_process_info(NULL, NULL, NULL, &rss, NULL, NULL, NULL, NULL);
  return rss;
}

#define BLOCK 4096
#define BATCH 1024
#define MiB 1048576.0
static void** mailbox;
static atomic_bool full, stop;
static atomic_llong live;

static void* producer(void* arg) {
  while (!stop) {
    void** b = malloc(BATCH * sizeof(void*));
    for (int i = 0; i < BATCH; i++) {
      b[i] = malloc(BLOCK);
      memset(b[i], 1, 64);
      live += mi_usable_size(b[i]);
    }
    while (full && !stop);
    mailbox = b; full = true;
  }
  return NULL;
}

static void* consumer(void* arg) {
  // remove this line to fix stats
  mi_free(mi_malloc(64));

  while (!stop) {
    while (!full) { if (stop) return NULL; }
    void** b = mailbox; full = false;
    for (int i = 0; i < BATCH; i++) {
      live -= mi_usable_size(b[i]);
      free(b[i]);
    }
    free(b);
  }

  return NULL;
}

int main(void) {
  pthread_t p, c;
  pthread_create(&p, NULL, producer, NULL);
  pthread_create(&c, NULL, consumer, NULL);

  for (int t = 1; t <= 4; t++) {
    sleep(1);

    mi_stats_t s = collect_stats();
    int64_t allocated = s.malloc_normal.current + s.malloc_huge.current;

    size_t rss = collect_rss();

    printf("t=%ds  stats = %10.1f MiB   actually live = %5.1f MiB   rss = %5.1f MiB\n",
           t, allocated / MiB, live / MiB, rss / MiB);
  }

  stop = true;
  pthread_join(p, NULL);
  pthread_join(c, NULL);
  
  mi_stats_t s = collect_stats();
  int64_t allocated = s.malloc_normal.current + s.malloc_huge.current;

  size_t rss = collect_rss();

  printf("after both threads exited: %.1f MiB (live %.1f MiB, rss %.1f MiB)\n",
         allocated / MiB, live / MiB, rss / MiB);

  return 0;
}

Example output:

t=1s  stats =    11898.4 MiB   actually live =  10.4 MiB   rss =  15.6 MiB
t=2s  stats =    24119.4 MiB   actually live =  11.2 MiB   rss =  15.6 MiB
t=3s  stats =    39981.5 MiB   actually live =  10.8 MiB   rss =  15.6 MiB
t=4s  stats =    54445.6 MiB   actually live =  10.8 MiB   rss =  15.6 MiB
after both threads exited: 8.1 MiB (live 8.0 MiB, rss 15.6 MiB)

Removing mi_free(mi_malloc(64)) causes the thread to write directly into the shared atomic stats, so the frees become visible immediately.

Environment

  • mimalloc v3.5.2 (636510a3), default CMake Release build (MI_STATS=1, MI_PROFILE=1, MI_DEFAULT_ALLOW_THP=2), malloc/free overridden by linking -lmimalloc.
  • Debian 12 (bookworm), kernel 6.1.0-41-amd64, x86-64; gcc 12.2.0, glibc 2.36.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the provided C reproduction and trace mi_stats_get(), mi_collect(), and the per-thread statistics merge behavior around frees from the consumer thread. Compare the results with and without the initial mi_free(mi_malloc(64)) call. Done means malloc_normal.current plus malloc_huge.current remains consistent with actually live allocations while the logging-like thread frees memory, without requiring that thread to allocate or exit.

Written by the indexing model from the issue text.

Assessment

Tech stack
c
Domain
operating-systems, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.