Un grazie speciale a Marco Alesiani per le sue correzioni e suggerimenti.
International reader? Read the post in English.
Quando diciamo “efficienza”, quasi sempre pensiamo “tempo”. Prima il codice fa il suo lavoro, più è efficiente.
E la memoria? Certo, oggi anche un portatile da quattro soldi arriva con “un secchio di RAM“… ma non basta mai. Il mio PC “sperpera” 1.4GB solo per restare acceso. Apro un browser, altri 300MB che se ne vanno*.
Oltre il danno, la beffa: usare la memoria è anche una delle operazioni più lente sui sistemi attuali*.
Ma non è semplice capire a quale riga del codice dare la colpa. Le new che scriviamo noi stessi? Qualche allocazione nascosta in una libreria? O è colpa di oggetti temporanei?
Come trovare facilemente la parte di codice che usa più memoria?
Questo articolo raccoglie qualche esperimento personale. Tutti gli errori sono “merito” dell’autore.
Usiamo un po’ di memoria
Il programma-giocattolo di oggi non ha nulla di particolare, se non una gran varietà di allocazioni di memoria con operator new.
/* Programma che alloca memoria a casaccio.
Niente delete, questo non e’ un articolo sui memory leak.*/
#include <string>
#include <memory>
#include <boost/shared_ptr.hpp>
#include <boost/make_shared.hpp>
#include "UnaClasseDelProgramma.h"
//
void h() {
UnaClasseDelProgramma * t = new UnaClasseDelProgramma();
}
void g() { h(); }
void f() { g(); }
void CreaUnaClasseDelProgramma() { f(); }
//
int main(int argc, char **argv) {
int * numero = new int(89);
std::string * test = new std::string("abc");
//
UnaClasseDelProgramma * oggetto = new UnaClasseDelProgramma();
CreaUnaClasseDelProgramma();
//
boost::shared_ptr<UnaClasseDelProgramma> smartPointer = boost::make_shared<UnaClasseDelProgramma>();
std::shared_ptr<UnaClasseDelProgramma> stdSmartPointer = std::make_shared<UnaClasseDelProgramma>();
return 0;
}
Compila, apri e… circa 42MB (misurati “alla buona” con /usr/bin/time -v).
Chi consuma tutta questa memoria?
Il modo corretto: memory profiler
Il concetto è familiare: il profiler “classico” indica per quanto tempo gira ogni funzione. Il memory profiler invece indica dove, quando e quanta memoria usa il programma.
Per esempio, ecco una parte di quello che Massif * dice del nostro programma.
Ma se lavorate in Windows: https://blogs.msdn.microsoft.com/vcblog/2015/10/21/memory-profiling-in-visual-c-2015/
Per iniziare, otteniamo (in ASCII art!) come l’uso della memoria cresce nel “tempo” – in realtà come cresce col numero di istruzioni eseguite:
MB 38.23^ ::::::::::::# | : # | : # | : # | : # | ::::::::::::: # | : : # | : : # | : : # | : : # | @@@@@@@@@@@@: : # | @ : : # | @ : : # | @ : : # | @ : : # | ::::::::::::@ : : # | : @ : : # | : @ : : # | : @ : : # | : @ : : # 0 +----------------------------------------------------------------------->Mi 0 6.203
Poi dei resoconti più dettagliati (le annotazioni “A”, “B” e “C” sono nostre):
-------------------------------------------------------------------------------- n time(i) total(B) useful-heap(B) extra-heap(B) stacks(B) -------------------------------------------------------------------------------- ... 9 4,313,116 30,080,056 30,072,844 7,212 0 99.98% (30,072,844B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc. ->99.73% (30,000,000B) 0x407F68: __gnu_cxx::new_allocator<char>::allocate(unsigned long, void const*) (new_allocator.h:104) | ->99.73% (30,000,000B) 0x407EDA: std::allocator_traits<std::allocator<char> >::allocate(std::allocator<char>&, unsigned long) (alloc_traits.h:491) | ->99.73% (30,000,000B) 0x407E80: std::_Vector_base<char, std::allocator<char> >::_M_allocate(unsigned long) (stl_vector.h:170) | ->99.73% (30,000,000B) 0x407DFB: std::_Vector_base<char, std::allocator<char> >::_M_create_storage(unsigned long) (stl_vector.h:185) | ->99.73% (30,000,000B) 0x407D27: std::_Vector_base<char, std::allocator<char> >::_Vector_base(unsigned long, std::allocator<char> const&) (stl_vector.h:136) | ->99.73% (30,000,000B) 0x407CB6: std::vector<char, std::allocator<char> >::vector(unsigned long, std::allocator<char> const&) (stl_vector.h:278) | ->99.73% (30,000,000B) 0x407C45: UnaClasseDelProgramma::UnaClasseDelProgramma() (UnaClasseDelProgramma.cpp:4) | A ===> ->33.24% (10,000,000B) 0x406611: main (main.cpp:20) | | | B ===> ->33.24% (10,000,000B) 0x406541: h() (main.cpp:10) | | ->33.24% (10,000,000B) 0x40656F: g() (main.cpp:12) | | ->33.24% (10,000,000B) 0x40657B: f() (main.cpp:13) | | ->33.24% (10,000,000B) 0x406587: CreaUnaClasseDelProgramma() (main.cpp:14) | | ->33.24% (10,000,000B) 0x40661A: main (main.cpp:21) | | | C ===> ->33.24% (10,000,000B) 0x406A72: _ZN5boost11make_sharedI21UnaClasseDelProgrammaIEEENS_6detail15sp_if_not_arrayIT_E4typeEDpOT0_ (make_shared_object.hpp:254) | ->33.24% (10,000,000B) 0x406626: main (main.cpp:23) | ->00.24% (72,844B) in 1+ places, all below ms_print's threshold (01.00%)
Vediamo subito che un terzo della memoria si spende alla riga 20 del main (A), dove c’è uno dei nostri new. Un altro 30% (B) lo alloca h() – che Massif mostra nello stack delle chiamate registrato al momento dell’allocazione. Seguendolo arriviamo alla chiamata a CreaUnaClasseDelProgramma() nel main. Massif cattura anche le allocazioni con shared pointer (C).
L’allocazione alla riga 24 non si vede perchè non è stata ancora eseguita e “intercettata” da Massif. Potrebbe comparire in uno snapshot successivo. Le altre allocazioni nel main sono “piccole” e aggregate nell’ultima riga.
Si vede subto che è il caso di dare un’occhiata al costruttore di UnaClasseDelProgramma. Che farà mai con uno std::vector che occupa il 99% della memoria?
Questo è già un ottimo aiuto, con poco sforzo. Volendo, Massif può fare di più. Può misurare la memoria usata “di nascosto” dal sistema per gestire l’heap (extra-heap – 7,212 byte nell’esempio), misurare lo stack…
Il metodo fai-da-te: override di operator new
In C++ si può sostituire l’operazione di creazione di un oggetto (new) con la propria.*
Quasi nessuno ha una buona ragione per farlo, ma noi si: non sappiamo usare il profiler intercettare le allocaioni nello heap.
Semplificando, basta definire la nostra versione di operator new (e dei suoi overload) in qualunque file del programma.
Se il memory profiler equivale al “time” profiler, questo trucco è paragonabile al classico snippet cout << tempoFine - tempoInizio;. Non magnificamente dettagliato e accurato, ma semplice e comunque utile.
Bastano poche righe di codice per avere qualcosa di rozzo, ma utilizzabile. E’ meglio compilare con i simboli di debug. Il codice per scrivere lo stack trace è valido probabilmente solo su Linux*.
Non c’è niente di portabile a così basso livello.
Per chi lavora nel mondo Microsoft: https://msdn.microsoft.com/en-us/library/windows/desktop/bb204633%28v=vs.85%29.aspx.
Sarebbe a dire:
#include <iostream>
//
#include <Windows.h> // Cattura degli stack trace.
#include <Dbghelp.h> // Lettura simboli di debug.
//
void StackTrace() {
/* Cattura lo stack trace vero e proprio. */
const ULONG doNotSkipAnyFrame = 0;
const ULONG takeTenFrames = 10;
const PULONG doNotHash = nullptr;
PVOID stackTrace[takeTenFrames];
const USHORT framesCaptured = CaptureStackBackTrace(
doNotSkipAnyFrame,
takeTenFrames,
stackTrace,
doNotHash
);
//
/* Prepara la tabella dei simboli per tradurre da indirizzi a righe di codice. */
const HANDLE thisProcess = GetCurrentProcess();
SymInitialize(thisProcess, NULL, TRUE); // Linkare Dbghelp.lib
//
for (ULONG i = 0; i < framesCaptured; i++) {
/*Estrae il nome della funzione. */
const size_t nameStringSize = 256;
SYMBOL_INFO * functionData = (SYMBOL_INFO*)malloc(sizeof(SYMBOL_INFO) + (nameStringSize + 1) * sizeof(char)); // +1 per il \0
functionData->MaxNameLen = nameStringSize;
functionData->SizeOfStruct = sizeof(SYMBOL_INFO);
SymFromAddr(thisProcess, (DWORD64)(stackTrace[i]), 0, functionData);
//
/* Va a cercare il file corrispondende alla chiamata.*/
DWORD displacementInLine;
IMAGEHLP_LINE64 lineOfCode;
lineOfCode.SizeOfStruct = sizeof(IMAGEHLP_LINE64);
SymGetLineFromAddr64(thisProcess, (DWORD)(stackTrace[i]), &displacementInLine, &lineOfCode);
//
std::cout << functionData->Name << " at "
<< lineOfCode.FileName << ":" << lineOfCode.LineNumber << std::endl;
}
}
.
// Il nostro new deve poter allocare la memoria…
#include <cstdio>
#include <cstdlib>
// …ma anche ispezionare lo stack e salvarlo in output.
#include <execinfo.h>
#include <unistd.h>
#include <fstream>
// Contiene std::bad_alloc – da lanciare in caso di errori.
#include <new>
//
/* Apre (una sola volta) e restituisce il file stream per salvare
gli stack. */
std::ofstream& filePerRisultati() {
static std::ofstream memoryProfile;
static bool open = false; // Init on 1st use, classico.
if (! open) {
memoryProfile.open ("allocations.txt");
open = true;
}
// Else, gestire gli errori, chiudere il file…
// Omettiamo per semplicità.
return memoryProfile;
}
//
/* Questa funzione “fa la magia” e scrive nel file lo stack trace al momento della chiamata
(compreso il suo stesso frame). */
void segnaLoStackTrace(std::ofstream& memoryProfile) {
// Registriamo 15 puntatori agli stack frame (bastano per il programma di prova).
const int massimaDimensioneStack = 15;
void *callStack[massimaDimensioneStack];
size_t frameInUso = backtrace(callStack, massimaDimensioneStack);
// A questo punto callStack è pieno di puntatori. Chiediamo i nomi delle
// funzioni corrispondenti a ciascun frame.
char ** nomiFunzioniMangled = backtrace_symbols(callStack, frameInUso);
// Scrive tutte le stringhe con i nomi delle funzioni nello stream per il debug.
for (int i = 0; i < frameInUso; ++i)
memoryProfile << nomiFunzioniMangled[i] << std::endl;
// A essere precisi, dovremmo rilasciare nomiFunzioniMangled con free…
}
//
/* Finalmente abbiamo tutti gli elementi per costruire il nostro operator new. */
void* operator new(std::size_t sz) {
// Allochiamo la memoria che serve al chiamante.
void * memoriaRichiesta = std::malloc(sz);
if (! memoriaRichiesta)
throw std::bad_alloc();
// Raccontiamo al mondo intero le nostre allocaioni.
std::ofstream& memoryProfile = filePerRisultati();
memoryProfile << "Allocation, size = " << sz << " at " << static_cast<void*>(memoriaRichiesta) << std::endl;
segnaLoStackTrace(memoryProfile);
memoryProfile << "-----------" << std::endl; // Separatore dei poveri…
return memoriaRichiesta;
}
Aggiungiamo l’operator new “taroccato” al nostro programma di prova. Questo è un esempio del risultato – riuscite a capire quale riga di codice alloca la memoria?
Allocation, size = 40 at 0x18705b0 ./overridenew(_Z14dumpStackTraceRSt14basic_ofstreamIcSt11char_traitsIcEE+0x3c) [0x40672c] ./overridenew(_Znwm+0xaf) [0x406879] ./overridenew(_ZN9__gnu_cxx13new_allocatorISt23_Sp_counted_ptr_inplaceI9SomeClassSaIS2_ELNS_12_Lock_policyE2EEE8allocateEmPKv+0x4a) [0x405d9e] ./overridenew(_ZNSt16allocator_traitsISaISt23_Sp_counted_ptr_inplaceI9SomeClassSaIS1_ELN9__gnu_cxx12_Lock_policyE2EEEE8allocateERS6_m+0x28) [0x405bef] ./overridenew(_ZSt18__allocate_guardedISaISt23_Sp_counted_ptr_inplaceI9SomeClassSaIS1_ELN9__gnu_cxx12_Lock_policyE2EEEESt15__allocated_ptrIT_ERS8_+0x21) [0x4059e2] ./overridenew(_ZNSt14__shared_countILN9__gnu_cxx12_Lock_policyE2EEC2I9SomeClassSaIS4_EJEEESt19_Sp_make_shared_tagPT_RKT0_DpOT1_+0x59) [0x4057e1] ./overridenew(_ZNSt12__shared_ptrI9SomeClassLN9__gnu_cxx12_Lock_policyE2EEC2ISaIS0_EJEEESt19_Sp_make_shared_tagRKT_DpOT0_+0x3c) [0x4056ae] ./overridenew(_ZNSt10shared_ptrI9SomeClassEC2ISaIS0_EJEEESt19_Sp_make_shared_tagRKT_DpOT0_+0x28) [0x40560e] ./overridenew(_ZSt15allocate_sharedI9SomeClassSaIS0_EIEESt10shared_ptrIT_ERKT0_DpOT1_+0x37) [0x405534] ./overridenew(_ZSt11make_sharedI9SomeClassJEESt10shared_ptrIT_EDpOT0_+0x3b) [0x405454] ./overridenew(main+0x9c) [0x4052e8] /lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xf0) [0x7f83fe991830] ./overridenew(_start+0x29) [0x405079] ----------- Allocation, size = 10000000 at 0x7f83fc9c3010 ./overridenew(_Z14dumpStackTraceRSt14basic_ofstreamIcSt11char_traitsIcEE+0x3c) [0x40672c] ./overridenew(_Znwm+0xaf) [0x406879] ./overridenew(_ZN9__gnu_cxx13new_allocatorIcE8allocateEmPKv+0x3c) [0x406538] ./overridenew(_ZNSt16allocator_traitsISaIcEE8allocateERS0_m+0x28) [0x4064aa] ./overridenew(_ZNSt12_Vector_baseIcSaIcEE11_M_allocateEm+0x2a) [0x406450] ./overridenew(_ZNSt12_Vector_baseIcSaIcEE17_M_create_storageEm+0x23) [0x4063cb] ./overridenew(_ZNSt12_Vector_baseIcSaIcEEC1EmRKS0_+0x3b) [0x4062f7] ./overridenew(_ZNSt6vectorIcSaIcEEC2EmRKS0_+0x2c) [0x406286] ./overridenew(_ZN9SomeClassC1Ev+0x3d) [0x406215] ./overridenew(_ZN9__gnu_cxx13new_allocatorI9SomeClassE9constructIS1_JEEEvPT_DpOT0_+0x36) [0x405e3a] ./overridenew(_ZNSt16allocator_traitsISaI9SomeClassEE9constructIS0_JEEEvRS1_PT_DpOT0_+0x23) [0x405d51] ./overridenew(_ZNSt23_Sp_counted_ptr_inplaceI9SomeClassSaIS0_ELN9__gnu_cxx12_Lock_policyE2EEC2IJEEES1_DpOT_+0x8c) [0x405b4a] ./overridenew(_ZNSt14__shared_countILN9__gnu_cxx12_Lock_policyE2EEC2I9SomeClassSaIS4_EJEEESt19_Sp_make_shared_tagPT_RKT0_DpOT1_+0xaf) [0x405837] ./overridenew(_ZNSt12__shared_ptrI9SomeClassLN9__gnu_cxx12_Lock_policyE2EEC2ISaIS0_EJEEESt19_Sp_make_shared_tagRKT_DpOT0_+0x3c) [0x4056ae] ./overridenew(_ZNSt10shared_ptrI9SomeClassEC2ISaIS0_EJEEESt19_Sp_make_shared_tagRKT_DpOT0_+0x28) [0x40560e] ...
…io non ci riesco. Dove sta “main+0xa8” nel mio programma? Fortunatamente, nel “mondo gnu/Linux” ci sono strumenti per fare il de-mangling e trovare i punti del codice corrispondenti agli indirizzi. Possiamo usarli, per esempio, in un semplice script.
#!/usr/bin/python
#
# C++filt fa il demangling dei nomi.
#
# addr2line converte i puntatori a codice (es. indirizzi di funzioni)
# alla coppia file:riga col codice corrispondente (se ci sono i simboli di debug).
#
# Il codice python dovrebbe essere portabile, ma non le utility a riga di comando.
#
import re
import subprocess
#
# Apre un sottoprocesso e gli passa dei comandi per la shell, poi ritorna il risultato in una stringa.
# Non molto efficiente, ma semplice.
def run_shell(command):
return subprocess.Popen(command, stdout=subprocess.PIPE).communicate()[0]
#
#
if __name__ == “__main__”:
total_size = 0;
#
# L’output ha 2 tipi di righe: quella con la dimensione dell’allocazione, e quella con uno stack frame.
size_line = re.compile(“Allocation, size = (\d+) at (\d+)”) # Allocation, size = <bytes> at <punto dell’heap>
stack_line = re.compile(“.*\((.*)\+.*\) \[(.*)\]”) # <immondizia>(nome mangled) [<puntatore al codice>]
#
allocations_file = open(“allocations.txt”)
for line in allocations_file:
match_size = size_line.match(line)
match_stack = stack_line.match(line)
#
# A scopo dimostrativo, accumulo il totale della memoria allocata.
# Un esempio di quello che si puo’ fare quando si controlla new!
if (match_size):
allocation_size = int(match_size.group(1))
total_size += allocation_size
print “Allocati ” + str(allocation_size)
#
elif (match_stack):
mangled_name = match_stack.group(1)
line_address = match_stack.group(2)
demangled_name = run_shell(["c++filt", "-n", mangled_name])
line_number = run_shell([“addr2line", “-e”, “./overridenew”, line_address])
#
# La formattazione non e’ molto professionale. Il -1 "gratuito" e’ per togliere un newline.
print”\t” + demangled_name[:-1] + “\n\t\t” + line_number,
#
# Rimette i separatori esattamente dov’erano.
else:
print line
#
print “\n total allocated size ” + str(total_size)
In alternativa, si può fare tutto a run time, con le utility di demangling dei compilatori. Per esempio quella di gcc. Personalmente preferisco tenere il codice di misurazione il più semplice possibile e “sbrigarmela” off-line. Con il mio script ottengo:
Allocati 40 segnaLoStackTrace(std::basic_ofstream<char, std::char_traits<char> >&) /home/stefano/projects/overrideNew/InstrumentedNew.cpp:31 operator new(unsigned long) /home/stefano/projects/overrideNew/InstrumentedNew.cpp:51 __gnu_cxx::new_allocator<std::_Sp_counted_ptr_inplace<UnaClasseDelProgramma, std::allocator<UnaClasseDelProgramma>, (__gnu_cxx::_Lock_policy)2> >::allocate(unsigned long, void const*) /usr/include/c++/5/ext/new_allocator.h:105 ... stack delle chiamate "interne" di shared_ptr... std::shared_ptr<UnaClasseDelProgramma> std::allocate_shared<UnaClasseDelProgramma, std::allocator<UnaClasseDelProgramma>>(std::allocator<UnaClasseDelProgramma> const&) /usr/include/c++/5/bits/shared_ptr.h:620 std::shared_ptr<UnaClasseDelProgramma> std::make_shared<UnaClasseDelProgramma>() /usr/include/c++/5/bits/shared_ptr.h:636 main /home/stefano/projects/overrideNew/main.cpp:25 __libc_start_main ??:0 _start ??:? ----------- Allocati 10000000 segnaLoStackTrace(std::basic_ofstream<char, std::char_traits<char> >&) /home/stefano/projects/overrideNew/InstrumentedNew.cpp:31 operator new(unsigned long) /home/stefano/projects/overrideNew/InstrumentedNew.cpp:51 __gnu_cxx::new_allocator<char>::allocate(unsigned long, void const*) /usr/include/c++/5/ext/new_allocator.h:105 ... stack delle chiamate interne di vector... std::vector<char, std::allocator<char> >::vector(unsigned long, std::allocator<char> const&) /usr/include/c++/5/bits/stl_vector.h:279 UnaClasseDelProgramma::UnaClasseDelProgramma() /home/stefano/projects/overrideNew/UnaClasseDelProgramma.cpp:4 (discriminator 2) ...
La prima allocazione sono 40 byte chiesti da make_shared. 24 per UnaClasseDelProgramma (che contiene un vector come membro – sizeof(vector) è 24), i restanti dovrebbero essere il control block dello shared pointer. La seconda allocazione sono i 10MB del famigerato costruttore di UnaClasseDelProgramma.
Bisogna faticare un po’ per decifrare gli stack, ma si riesce a capire che la riga misteriosa era std::shared_ptr
Compito per casa: quante allocazioni ci sarebbero con std::shared_ptr<UnaClasseDelProgramma> notSoSmartPointer(new UnaClasseDelProgramma());
?*
In un test ho misurato:
24 byte per l’istanza di UnaClasseDelProgramma
10 MB per il contenuto del vector
24 byte per lo shared pointer.
Giudiacando dalle implementation notes, penso che la differenza sia nel contenuto del control_block dello shared pointer.
Riassumendo…
I programmatori combattono da sempre con la memoria, vuoi perché è poca, vuoi perché è lenta. Come per tutti i colli di bottiglia, non ci si può fidare dell’istinto. Abbiamo visto che esistono strumenti appropriati (i memory profiler) per misurare il consumo di memoria. Abbiamo scoperto che, male che vada, esistono strumenti “casarecci” che possiamo costruirci da soli con il “classico hack da C++”, manipolando operator new.
Trovate il codice degli esempi “pronto da compilare” sul repo GitHub di ++It.