DEV Community

Matheus de Camargo Marques
Matheus de Camargo Marques

Posted on • Originally published at github.com

PON-BEAM: um paradigma orientado a notificações dentro da máquina virtual do Erlang

PON-BEAM: um paradigma orientado a notificações dentro da máquina virtual do Erlang

1. A pergunta que move este projeto

Todo programador Elixir ou Erlang já ouviu o mantra: "a BEAM é uma máquina virtual altamente concorrente, com milhões de processos leves, trocas de contexto em microsegundos e escalabilidade quase linear". E isso é verdade — para o modelo de programação.

Mas quando você abre o código em C da ERTS (Erlang Run-Time System) e olha como a máquina funciona por dentro, encontra uma surpresa: a BEAM é um motor híbrido. Ela tem notificações para algumas coisas, mas ainda puxa (polling) e faz scans lineares em vários dos subsistemas mais críticos:

Subsistema Mecanismo no BEAM stock (OTP 30) Custo
receive seletivo Scan linear da mailbox (cada cláusula contra cada mensagem) $O(N \times M)$
Timers Timer wheel com ticks periódicos de polling O(1) por tick, mas a CPU nunca dorme
Scheduler SMP Run-queue pollada / busy-spin quando ocioso 5–30% de um core desperdiçado
Coletor de lixo Varredura de heap por raízes $O(\text{heap total})$
ETS Lookups com lock + busca contention na chave quente

Esse é exatamente o tipo de problema que o Paradigma Orientado a Notificações (PON / NOP), criado pelo professor Dr. Jean Marcelo Simão (UTFPR, 2005–2009), se propõe a eliminar: redundância temporal (reavaliações desnecessárias) e redundância estrutural (código de busca repetido).

O PON-BEAM (repositório: matheuscamarques/pon_beam) é uma re-arquitetura completa da BEAM onde todo subsistema interno vira uma entidade reativa PON: mensagens empurram notificações, em vez de a máquina puxar estados.

A tese central: Nenhum trabalho anterior aplicou o PON como princípio fundacional do motor da VM. A literatura NOP implementa o paradigma em cima de plataformas existentes (C++, Java, FPGA, Erlang). O PON-BEAM propõe o PON como o próprio design da máquina.

Este artigo explica, um a um, cada componente PON que foi integrado à VMBEAM — o que ele substitui, como foi implementado em C na ERTS, e quais ganhos empíricos o harness de benchmarks mediu.


2. O paradigma PON em 5 minutos

Antes de falar de cada componente, os conceitos básicos do PON:

  • Entidade: qualquer elemento da aplicação (um processo Erlang, um scheduler, um objeto de heap) tratado como reativo e desacoplado. Uma entidade contém dois tipos de elementos:
    • FAD (Facts / dados): o estado da entidade.
    • FBE (Fundamental Behavior Element / procedimentos): o comportamento.
  • Premise: uma condição mínima sobre os fatos de uma entidade. Ela só é avaliada quando notificada — e não periodicamente. Ex.: "uma mensagem {gen_call, From, Req} chegou na mailbox".
  • Condition: uma conjunção de Premises. Quando todas as Premises de uma Condition estão satisfeitas, a Condition notifica uma Instigação.
  • Instigation: dispara a execução de um método (FBE) quando sua condição causal/temporal se torna verdadeira. No PON-BEAM, os timers são Instigações temporais.
  • Notificação: o único mecanismo de colaboração entre entidades. É ponto-a-ponto (quem muda sabe quem deve ser avisado), reativa (só dispara quando o fato muda) e sem polling.

A inversão de controle é radical: no modelo imperativo clássico a entidade pergunta "o estado mudou?". No PON, o estado muda e avisa quem o observa.

flowchart LR
    subgraph Stock ["BEAM Stock (OTP 30) — Polling e Scan"]
        direction TB
        A["receive: scan linear da mailbox O(N·M)"]
        B["timer wheel: ticks periódicos"]
        C["scheduler: busy-spin 5–30% CPU"]
    end
    subgraph Pon ["PON-BEAM — Grafos Reativos de Push"]
        direction TB
        Cond["Condition (chegada de estado/mensagem)"]
        Prem["Premise (slot de pattern match)"]
        Instig["Instigation (salto O(1) de execução)"]
        Cond -->|"empurra evento"| Prem
        Prem -->|"satisfaz"| Instig
    end
    Stock ==>|"re-arquitetado como"| Pon
Enter fullscreen mode Exit fullscreen mode

3. A fundação: um overlay compilável sobre o OTP

Antes de qualquer subsistema, a Fase 0 estabeleceu como o PON-BEAM existe junto do OTP stock. As regras de ouro do projeto:

  1. Nunca tocar no baseline. O OTP original vive imutável na branch otp-30.0-rc0-stock.
  2. Toda modificação C fica dentro de #ifdef PON_BEAM. O código original permanece intacto — o PON-BEAM é um overlay compilável, não um fork divergente.
  3. Um artefato por fase: um #ifdef, um benchmark diferencial e uma telemetria.

Isso garante 100% de compatibilidade retroativa: o formato .beam, a ABI de NIFs e o protocolo de distribuição não mudam. Um mesmo pool de código compila beam.smp (stock) ou beam.ponbeam.smp (PON) dependendo do flag -DPON_BEAM.

A telemetria da prova: pon_stats.h

Para medir que o comportamento reativo realmente aconteceu (e não só intuir), cada componente PON escreve contadores thread-local por scheduler:

/* pon_stats.h — um contador por scheduler (thread-local) */
typedef struct {
    /* === PON-Receive === */
    Uint64 premises_registered;      /* Premises registradas */
    Uint64 premise_notifications;    /* Premises notificadas na chegada de msg */
    Uint64 mailbox_scans_avoided;    /* Scans lineares evitados pelo advance O(1) */
    Uint64 messages_classified;      /* Mensagens classificadas por tipo */
    /* === PON-Timer === */
    Uint64 timerfd_created;
    Uint64 timerfd_expirations;
    /* === PON-Scheduler === */
    Uint64 condition_wakeups;
    Uint64 scheduler_idle_blocks;
    /* === PON-ETS === */
    Uint64 ets_watchers_registered;
    Uint64 ets_watcher_hits;
    /* === PON-GC === */
    Uint64 gc_notifications_sent;
    Uint64 gc_scans_avoided;
    Uint64 gc_incremental_steps;
    /* === Temporais === */
    Uint64 pon_overhead_us;
} PonStats;
Enter fullscreen mode Exit fullscreen mode

Os contadores são expostos ao Erlang via erlang:system_info(pon_stats) e cada benchmark os lê para validar o mecanismo (ex.: nº de mailbox_scans_avoided confirma que o receive pulou o scan).


4. Componente #1 — PON-Receive (Fase 1): Premises na mailbox

O problema: O(N × M)

O receive seletivo do Erlang é a fundação da concorrência da BEAM, mas o intérprete do OTP faz o scan: quando você tem

receive
    {gen_call, From, Req} -> handle(From, Req);
    {cast, Msg}           -> handle_cast(Msg);
    stop                  -> shutdown()
end
Enter fullscreen mode Exit fullscreen mode

e a mailbox tem N mensagens não relacionadas, o intérprete tenta, em ordem de chegada, cada uma das M cláusulas contra cada uma das N mensagens até achar um match. Um gen_server com 10.000 mensagens pendentes custa 4.500µs; com 100.000, 82.000µs.

A solução: Premises

Uma Premise PON-BEAM é um slot de pattern match compilado registrado pelo processo no momento do receive. Quando uma mensagem compatível chega na mailbox, ela notifica a Premise — em vez de a mailbox ser escaneada mais tarde.

/* pon_premise.h — Entidade Premise adaptada à mailbox da BEAM */
typedef struct ErtsPremise_ {
    Eterm                pattern;        /* Padrão compilado (como termo) */
    int                  (*match_fn)(Eterm); /* Função de match otimizada */
    int                  has_match;      /* 1 se há mensagem casada disponível */
    Eterm                matched_term;   /* Termo da mensagem casada */
    struct erl_mesg      *matched_msg;   /* Referência para a mensagem */
    Uint                 clause_index;   /* Índice da cláusula (ordem) */
    struct ErtsPremise_  *next_premise;  /* Lista ligada de premises */
} ErtsPremise;
Enter fullscreen mode Exit fullscreen mode

O fluxo tem três momentos:

1. Classificação rápida por tag de tipo. Na chegada da mensagem, erts_pon_notify_premises() extrai o 1º elemento da tupla e usa os 8 bits baixos dele para indexar um dos 256 buckets (pon_type_tag), mantendo um contador de mensagens por bucket. Isso isola, em $O(1)$, o subconjunto de mensagens potencialmente relevantes:

#define PON_NUM_TYPE_BUCKETS (1 << 8)
#define pon_type_tag(term) ((Uint)(term) & (PON_NUM_TYPE_BUCKETS - 1))
Enter fullscreen mode Exit fullscreen mode

2. Notificação da Premise. Cada Premise possui um match_fn especializado (gerado pelo compilador) ou o fallback erts_pon_default_match(). Se casa, a Premise guarda o termo, a mensagem e uma sequência de chegada global monotônica (erts_pon_next_msg_seq()), necessária para preservar a semântica do receive seletivo multi-cláusula (a mensagem mais antiga casa primeiro).

3. Advance O(1) — o pulo do gato. O hook central é no enqueue (lado do envio): cada mensagem recebe um pon_in_link, que é o endereço do ponteiro da fila que aponta para ela (prev->next ou &sig_qs.first):

/* erl_proc_sig_queue.c — grava o link de entrada fora da janela do receive */
#ifdef PON_BEAM
    if (rp->pon_premises && num_msgs && !is_to_buffer) {
        if (ERTS_SIG_IS_MSG(first))
            first->pon_in_link = this;
    }
#endif
Enter fullscreen mode Exit fullscreen mode

Depois, no loop_rec_end do interpretador, erts_pon_advance_to_matched() reposiciona o save pointer da fila principal diretamente para o endereço da mensagem casada — sem caminhar a lista, sem pattern matching por mensagem:

/* pon_premise.c — fast-path do selective receive */
if (m && m->pon_in_link && *m->pon_in_link == m
    && qs->save != m->pon_in_link) {
    qs->save = m->pon_in_link;              /* salto O(1) */
    PON_STATS_INC(mailbox_scans_avoided);
    return 1;
}
Enter fullscreen mode Exit fullscreen mode

A validação *pon_in_link == matched_msg é um gate de segurança: se a mensagem já foi consumida por outra via ou o gambar foi alcançado pelo scan normal, a Premise é marcada obsoleta e o scan linear segue como fallback (correto, apenas mais lento). Os casos de caminhos não instrumentados (buffers multi-mensagem, flush de sig_inq, concatenação de cadeias) também registram pon_in_link — todos os caminhos de entrada da mensagem estão cobertos.

Resultado medido

N (mensagens na mailbox) Stock PON-BEAM Speedup
100 45.2 µs 8.5 µs 5.3×
1.000 320 µs 9.2 µs 34.8×
10.000 4.500 µs 10 µs 445×
100.000 82.000 µs 12 µs 6.665×

A latência do receive PON no plota ~15µs em todo o range de 100 a 50.000 mensagens — o gap cresce monotonicamente com N, confirmando uma inversão assintótica ($O(N \times M) \to O(1)$), e não um ganho de constante.


5. Componente #2 — PON-Timer (Fase 2): Instigações com timerfd

O problema: o timer wheel nunca dorme

A BEAM usa um timer wheel com resolução de 1ms. Mesmo com zero timers ativos, o sistema de timers executa ticks periódicos para varrer o wheel em busca de expirações — consumindo ~3% de um core sem fazer nada. Com 50.000 timers registrados, o custo de checagem chega a 50.000.000 checagens/seg com ~15% de CPU.

A solução: Instigações temporais com o kernel

Uma Instigation PON é um evento futuro que, quando satisfeito, notifica um processo-alvo. No PON-BEAM a expiração de um timer fica a cargo do kernel Linux: cada timer vira um timerfd monitorado por epoll. A VM só acorda no único próximo evento vencido — e nunca varre o wheel.

/* pon_timer.c — cria o timerfd e registra na epoll */
int pon_timer_instigation_create(ErtsTimerInstigation *inst) {
    ...
    tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
    spec.it_value.tv_sec  = (long)(timeout_ms / 1000);
    spec.it_value.tv_nsec = (long)((timeout_ms % 1000) * 1000000ULL);
    timerfd_settime(tfd, 0, &spec, NULL);
    ev.events   = EPOLLIN;
    ev.data.ptr = (void *)inst;
    epoll_ctl(pon_timer_epoll_fd, EPOLL_CTL_ADD, tfd, &ev);
    inst->timer_fd = tfd;
}
Enter fullscreen mode Exit fullscreen mode

A entidade ErtsTimerInstigation guarda o fd, o alvo (target) e a mensagem a entregar na expiração:

/* pon_instigation.h */
typedef struct {
    ErtsInstigation   base;       /* type, fired, target, message, next */
    int               timer_fd;   /* timerfd (−1 se inativo) */
    uint64_t          expiration_ms;
} ErtsTimerInstigation;
Enter fullscreen mode Exit fullscreen mode

pon_timer_process_expirations() faz um epoll_wait(fd, events, 64, 0) não-bloqueante e dispara cada Instigação vencida — drenando as expirações em lote, sem custo proporcional ao número total de timers.

Resultado medido

Cenário Stock PON-BEAM
CPU ociosa com 0 timers ~3% de 1 core 0.0%
10 timers (1s) ~3.1% 0.001%
50.000 timers (1s) ~15% CPU / 50M checagens/s ~0.1% / 5 checagens/s

O custo total de timers registrados cai de "varredura do wheel" para "quase nada" — $10.000.000\times$ em checagens por segundo.


6. Componente #3 — PON-Spawn (Fase 3): notificação no agendamento

O problema

Cada spawn percorre o caminho de criação de processo (alocar PCB, inscrever no process table, inserir na run queue) e a latência média de criação fica em ~15µs — com uma cauda longa de jitter sob rajadas.

A solução

A Fase 3 integrou um hook de notificação no caminho de agendamento: quando um processo é criado (ou tornando-se pronto), erts_schedule_process() dispara erts_pon_schedule_notify(p) — que incrementa os contadores de Condition e avisa o scheduler-alvo que há trabalho disponível:

/* erl_process.c — hook no agendamento */
void
erts_schedule_process(Process *p, erts_aint32_t state, ErtsProcLocks locks)
{
    schedule_process(p, state, locks);
#ifdef PON_BEAM
    erts_pon_schedule_notify(p);
#endif
}
Enter fullscreen mode Exit fullscreen mode

Resultado medido

  • Latência média de spawn: ~15µs → ~8µs (~2×).
  • Tempestade de 50.000 spawns: pico de 86µs (stock) → 69µs (−19,7%), com a cauda longa de jitter eliminada — uma distribuição mais estreita e previsível.

7. Componente #4 — PON-Scheduler (Fase 4): Conditions com eventfd + epoll

O problema: o busy-spin ocioso

No OTP stock, quando não há processos prontos, o scheduler cai num loop de checagem da run queue que queima 5–30% de um core por scheduler. Num servidor de 32 cores, isso pode ser 1,6–9,6 cores inteiros girando à toa. A reativação de um processo ocioso mede 10–100µs.

A solução: a Condition

Uma Condition PON é a conjunção de Premises: quando as premissas de um processo estão satisfeitas, ela notifica o scheduler. No PON-BEAM, a Condition substitui a run queue passiva por uma máquina de notificação kernel-space:

  • eventfd: contador kernel onde o scheduler dorme (epoll_wait) até um byte ser escrito.
  • epoll: multiplexa os eventfds de wakeup + os timerfd das Instigações da Fase 2.
  • ready_list lock-free: uma pilha de processos prontos inserida por CAS (compare-and-swap), tolerante a produtores concorrentes.
/* pon_condition.c — notificação do scheduler */
void pon_condition_notify(ErtsCondition *cond, void *process) {
    void **node = (void **)process;
    void *old_head;
    do {
        old_head = atomic_load_explicit((atomic_uintptr_t *)&cond->ready_list,
                                        memory_order_acquire);
        *node = old_head;
    } while (!atomic_compare_exchange_weak_explicit(
        (atomic_uintptr_t *)&cond->ready_list, (uintptr_t *)&old_head,
        (uintptr_t)process, memory_order_release, memory_order_acquire));
    cond->notify_count++;
    if (!cond->satisfied) {                    /* só acorda se estiver dormindo */
        cond->satisfied = 1;
        uint64_t one = 1;
        write(cond->wake_fd, &one, sizeof(one));
    }
}
Enter fullscreen mode Exit fullscreen mode

E o consumidor — o scheduler — bloqueia até o kernel entregar a notificação:

void *pon_condition_wait(ErtsCondition *cond) {
    ...
    int nfds = epoll_wait(cond->epoll_fd, events, PON_CONDITION_BATCH_SIZE, -1);
    if (nfds > 0) { cond->satisfied = 1; continue; }  /* volta a drenar a lista */
}
Enter fullscreen mode Exit fullscreen mode

O padrão check-then-drain é crucial para não perder wakes: o consumidor tenta primeiro drenar a ready_list via CAS; se o CAS falha (produtor chegou no meio), tenta de novo; se a lista está vazia mas o eventfd tem dados, lê o eventfd; só então bloqueia em epoll_wait. Notificações perdidas são impossíveis no design.

Uma nota de transparência sobre esta fase (que vale para todo o projeto): o histórico de commits documenta inclusive o bug que o caminho seguro precisou corrigir — uma versão anterior usava o primeiro byte do struct Process como node da ready_list, mas o 1º campo é o PID (common.id); sobrescrevê-lo corrompia Eterms e produzia size_object: bad tag. A implementação final não embute nada no Process: o avanço real para uma ready_list com node próprio + consumo real pelo scheduler é o que a fase seguinte formaliza (ver docs/STORYTELLING.md).

Resultado medido

Cenário Stock PON-BEAM
CPU ociosa (0 processos ativos) 5–30% de 1 core 0.0%
Latência de reativação 10–100 µs ~1 µs (~50×)
100 processos I/O-bound 10–40% 10–20%

8. Componente #5 — PON-ETS (Fase 5): side-table de watchers

O problema

A tabela ETS é o "Redis dentro da BEAM", mas consultas repetidas à mesma chave quente pagam a cada acesso: lock da tabela + caminhada da estrutura (hash/tree). Sob contenção, o throughput cai drasticamente.

A solução: watchers desacoplados

Em vez de pesquisar a chave repetidamente, processos observam chaves: um registro lateral (completamente desacoplado da tabela) mapeia (table_id, key_hash) → processos interessados. Em 1024 buckets (PON_ETS_WATCHER_BUCKETS):

/* pon_ets.h — registro lateral de watchers */
#define PON_ETS_WATCHER_BUCKETS 1024

typedef struct PonEtsWatcher_ {
    uint64_t           table_id;
    uint64_t           key_hash;
    uint64_t           process_id;
    int                active;
    struct PonEtsWatcher_ *next;
} PonEtsWatcher;
Enter fullscreen mode Exit fullscreen mode
/* pon_ets.c — notifica todos os watchers de uma chave */
int pon_ets_watcher_notify(PonEtsWatcherRegistry *reg,
                           uint64_t table_id, uint64_t key_hash) {
    unsigned bucket = pon_ets_watcher_bucket(table_id, key_hash);
    ...
    while (w) {
        if (w->active && w->table_id == table_id && w->key_hash == key_hash) {
            /* envia {ets_change, TableId, Key} para p->mailbox (via Fases 1+4) */
            notified++;
            PON_STATS_INC(ets_watcher_hits);
        }
        w = w->next;
    }
    return notified;
}
Enter fullscreen mode Exit fullscreen mode

O registro inclui limpeza por processo (pon_ets_watcher_remove_process) — quando um watcher morre, suas observações são removidas em varredura pelos buckets.

Resultado medido

  • 1.000 lookups repetidos na mesma chave: 200µs → 0,8µs (250×) — a observação vira notificação, "busca" vira "push".
  • Chave quente sob contenção: stock 1,70M ops/s (1,25M leituras + 0,45M escritas) → PON-BEAM 11,96M ops/s (9,97M leituras + 1,99M escritas) ≈ 7× de throughput combinado, porque a notificação bypassa a busca e a trava.

9. Componente #6 — PON-Compiler (Fase 6): o receive vira Premise

O problema

Para as Premises do PON-Receive terem match_fn especializados, alguém precisa gerá-los. Um receive normal desce pelo pipeline (Core Erlang → SSA → BEAM opcodes) como loop_rec/wait tradicional.

A solução: parse transform para Premises

A Fase 6 entrega o pon_compiler, um parse_transform que reescreve blocos receive do AST para o trio reativo registrar-premises → receber → despachar:

%% harness/benchmarks/lib/pon_compiler.erl
%% Uso: -compile({parse_transform, pon_compiler}).

%% Cada cláusula vira uma Premise
build_premise_list(L, Clauses) ->
    Patterns = lists:map(
        fun({clause, _CL, [Pat], _Gs, _Bd}) ->
            {tuple, 0, [pat_to_term(0, Pat), {atom, 0, true}, {integer, 0, 0}]}
        end, Clauses),
    list_to_ast(L, Patterns).

build_pon_receive(L, Clauses, After) ->
    MsgVar = {var, 0, 'PonMsg'},
    Register = {call, 0, {remote, 0, {atom, 0, pon_runtime},
                          {atom, 0, register_premises}}, [PremiseList]},
    Recv = {match, 0, MsgVar,
            {call, 0, {remote, 0, {atom, 0, pon_runtime},
                       {atom, 0, receive_msg}}, []}},
    Dispatch = build_dispatch(L, Clauses, MsgVar),
    Unreg = {call, 0, {remote, 0, {atom, 0, pon_runtime},
                       {atom, 0, unregister_premises}}, []},
    [Register, Recv, Dispatch, Unreg].
Enter fullscreen mode Exit fullscreen mode

O runtime (pon_runtime.erl) fornece register_premises/1, receive_msg/0, receive_msg_timeout/1 e unregister_premises/0 — e toda a infra da Fase 1 (buckets, pon_in_link, advance O(1)) se conecta abaixo. O plano de engenharia registra também o objetivo de lidar com Guards e After na forma SSA nativa (Premises nativas no bytecode) como evolução da fase.

Resultado

O benchmark fase6_compile.erl e o fase6_stress_compiler.erl medem a compilação de módulos com receives transformados via pon_compiler, validando que o circuito compilação-notificação entrega receive nativo O(1) ao invés do scan linear.


10. Componente #7 — PON-GC (Fase 7): coleta tri-color por notificação (Dijkstra)

O problema

O GC major do OTP é stop-the-world: ele varre o heap inteiro a partir das raízes procurando vivos. Num heap de 100MB onde só 10% dos objetos estão vivos, o GC ainda examina os 100MB — o custo é proporcional ao heap total, não aos vivos.

A solução: marcação por notificação

O PON-GC adapta o algoritmo tri-color de Dijkstra (1978) para o mundo PON: em vez de "varreia pelo mark", a descoberta de vivos é um grafo de notificações:

  • WHITE → ninguém notificou (lixo suspeito).
  • GRAY → foi notificado (vivo), está na fila de processamento.
  • BLACK → notificou suas referências (vivo, verificado).
/* pon_gc.c — cores */
static void enqueue_gray(PonGcState *gc, PonGcNode *node) {
    if (!node || node->color != PON_GC_WHITE) return;
    node->color = PON_GC_GRAY;
    gc->live_nodes++;
    /* append na fila gray (WHITE -> GRAY) */
}
Enter fullscreen mode Exit fullscreen mode

O mark é uma rajada de notificações: as raízes notificam suas referências (GRAY), cada GRAY notifica as suas (criando novos GRAY) e fica BLACK, até a fila esvaziar.

uint64_t pon_gc_mark(PonGcState *gc) {
    for (i = 0; i < gc->num_roots; i++)
        enqueue_gray(gc, gc->roots[i]);     /* Fase 1: raízes notificam */
    while (gc->gray_head) {                 /* Fase 2: propaga até esvaziar */
        PonGcNode *node = dequeue_gray(gc);
        propagate(gc, node);                /* notifica refs; vira BLACK */
    }
    gc->cycles++;
}
Enter fullscreen mode Exit fullscreen mode

O sweep sim é barato: só itera a lista global e libera quem permaneceu WHITE — objetos que nunca foram "examinados" individualmente:

if (node->color == PON_GC_WHITE) {
    gc->dead_nodes++;                       /* morto: nunca notificado */
    gc->bytes_freed += node->data_size;
    pon_gc_node_free(gc, node);
} else {
    node->color = PON_GC_WHITE;             /* vivo: volta a WHITE p/ próximo ciclo */
}
Enter fullscreen mode Exit fullscreen mode

E o GC também é incremental: pon_gc_step(gc, max_notifications) processa até N notificações por fatia de tempo, devolvendo 1 quando o mark termina — o que dá origem a finas de pausa (P99 mais curto).

A integração com a ERTS é via o estado por processo (p->pon_gc) + BIFs de diagnóstico:

pon_gc:register_objects(Sizes) -> [Id]   %% cria os nós do grafo
pon_gc:add_root(Id) -> true | false       %% registra raiz
pon_gc:add_ref(From, To) -> true | false  %% aresta from -> to
pon_gc:collect() -> map                    %% colhe lixo WHITE
pon_gc:dump() -> map                       %% telemetria
Enter fullscreen mode Exit fullscreen mode

E o hook real no ciclo de GC do processo (erts_pon_gc_process_gc em erl_gc.c:816) roda o mark por notificação e acumula nos contadores gc_notifications_sent, gc_scans_avoided e gc_incremental_steps.

Resultado medido

Cenário Stock PON-BEAM
Heap 100MB, 10% vivos varre 100MB inteiros varre só ~10MB vivos (10×)
Heap 90% lixo, latência média de GC ~645 ms (P99 ~940ms) ~475 ms (−26,3%), fila P99 menor

O custo assintótico inverte de $O(\text{heap total})$ para $\mathcal{O}(V_{\text{live}} + E_{\text{live}})$ — só o que está vivo é percorrido. O custo colateral previsível é a memória extra do grafo de nós (by_id, refs, listas) — documentado como "overhead predizível" de ~3GB para 1M de processos no cenário de type_queues.


11. Visão consolidada: onde cada entidade PON vive

ERTS C Execution Engine                        Erlang Compiler Pass
┌──────────────────────────────────────┐   ┌──────────────────────────┐
│ PON-Receive   → ErtsPremise        │   │ PON-Compiler            │
│ PON-Timer     → ErtsTimerInstigation│   │ pon_compiler.erl       │
│ PON-Scheduler → ErtsCondition      │   │ pon_runtime.erl       │
│ PON-Spawn     → notify hook        │   └──────────┬───────────────┘
│ PON-ETS       → PonEtsWatcher      │              │ gera Premises
│ PON-GC        → PonGcNode          │              ▼
└───────┬──────────────┬─────────────┘   ┌──────────────────────────┐
        │              │                 │ Harness & Verificação    │
        └──────────────┼────────────────►│ pon_harness / pon_diff  │
                       │                 │ TLA+ · Coq · Frama-C ·  │
                       │                 │ PropEr                  │
                       └────────────────►└──────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
Fase Componente Entidade PON Mecanismo substituído Ganho
1 PON-Receive ErtsPremise scan $O(N \times M)$ da mailbox 445× → 6.665×
2 PON-Timer ErtsTimerInstigation timer wheel polls 0% CPU ociosa; 10M× checagens
3 PON-Spawn hook de notificação agendamento passivo ~2×
4 PON-Scheduler ErtsCondition busy-spin 0% CPU ociosa; ~50× wakeup
5 PON-ETS PonEtsWatcher lock + busca 250× leituras; ~7× throughput
6 PON-Compiler parse transform / SSA recibos manuais Premises nativas O(1)
7 PON-GC PonGcNode varredura de heap total 10× redução de scan

12. Verificação formal: as 4 pilastras

Ganhos assintóticos não são suficientes — semântica deve permanecer idêntica. O projeto sustenta que "trocar scan por notificação preserva a semântica exata do Erlang, sem deadlocks nem lost wakeups" com 4 pilastras matemáticas:

Pilastra Ferramenta Verifica
1. Model checking TLA+/TLC Invariantes do scheduler (SchedulerWakeup.tla, ConditionNotify.tla), mailbox (MailboxPON.tla, PremiseMatch.tla), timer (TimerWheel.tla), GC tri-color (TriColorGC.tla), lock-free (AtomicLockFreeInvariants.tla), sincronização distribuída (DistributedNodeSync.tla), equivalência do compilador (CompilerSemanticsEquivalence.tla)
2. Prova de teoremas Coq TriColorGC.v (segurança do GC) e PONComplexity.v (limites assintóticos O(1))
3. Análise estática/simbólica Frama-C/ACSL e KLEE Contratos de memória C e cobertura de caminhos LLVM
4. Teste de propriedades PropEr Equivalência estado-a-estado Stock vs PON
make verify-all   # TLA+ + PropEr + Frama-C
make verify-tla   # model checker TLC
make verify-proper
make verify-c
Enter fullscreen mode Exit fullscreen mode

Quando eu, como engenheiro, digo "ecologia sem trade-offs", esta é a evidência: o radar holístico (idle CPU, mailbox, ETS, GC, spawn) mostra o PON-BEAM dominando em todos os eixos simultaneamente — ao contrário de otimizações clássicas que trocam uma dimensão por outra.


13. Como reproduzir você mesmo

git clone https://github.com/matheuscamarques/pon_beam.git
cd pon_beam
make build-stock        # OTP 30 baseline → /opt/erlang-30-stock
make build-pon          # PON-BEAM ERTS → /opt/erlang-30-pon
make build-pon-debug    # PON-BEAM com telemetria
make benchmark          # roda o harness diferencial completo
make benchmark-fase1    # só a fase 1
make report             # abre o HTML com os gráficos comparativos
Enter fullscreen mode Exit fullscreen mode

Requisitos: Linux com kernel ≥ 4.18 (para eventfd/timerfd), GCC ≥ 9 / Clang ≥ 11, make/autoconf. Tudo roda também em Docker (make docker-build, make bench-docker).


14. Conclusão

O PON-BEAM responde a uma pergunta que a literatura NOP nunca tinha formulado: e se o paradigma de notificações fosse o projeto da máquina — e não apenas um estilo de aplicação?

Cada subsistema da BEAM foi convertido em entidade reativa:

  • Mailbox → Premises com advance O(1) do save pointer, sem scan linear (6.665×).
  • Timers → Instigações timerfd/epoll, o kernel conta o tempo por nós (0% CPU ociosa).
  • Scheduler → Conditions eventfd, o scheduler dorme de verdade (0% busy-spin).
  • ETS → watchers push em vez de lookups pull (250×).
  • GC → marcação tri-color por notificação, custo proporcional aos vivos (10×).
  • Compiler → parse transform que gera as Premises nativamente.

E, mais importante para a engenharia de VM: tudo sob #ifdef PON_BEAM, 100% retro-compatível, medido por benchmark diferencial e garantido por 4 pilastras formais. Não é uma otimização de constante — é uma inversão de complexidade assintótica.

"Changing how a virtual machine thinks is harder than building a new one — but forty years of compatibility legacy isn't built in a day." — Matheus de Camargo Marques, 2026


Referências

  • Repositório: github.com/matheuscamarques/pon_beam
  • Tese: docs/EX-37-pon-beam-arquitetura-orientada-a-notificacoes.mdPON-BEAM, a Notification-Oriented Virtual Machine Architecture
  • Plano de engenharia: docs/EX-38-pon-beam-plano-de-engenharia.md
  • Relatórios de fase: docs/RPT-01..RPT-10
  • Storytelling / saga técnica: docs/STORYTELLING.md
  • Paradigma PON: Simão & Stadzisz (2008–2009); Negrini (2019); Linhares (2015)
  • Código ERTS: otp/erts/emulator/beam/pon_{premise,condition,timer,ets,gc}.c e otp/erts/include/internal/pon_*.h

Este artigo acompanha o projeto de tese de **Matheus de Camargo Marques, licenciado sob Apache 2.0 (mesma licença do Erlang/OTP). Código de exemplo simplificado para legibilidade — o código real está no repositório com todas as guardas de segurança e validações.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

A substituição do mecanismo de receive seletivo por uma abordagem baseada em notificações no PON-BEAM é particularmente interessante, pois elimina a varredura linear da mailbox e reduz a complexidade para O(1). Isso me leva a questionar como a implementação do PON-BEAM lida com a ordem de entrega de mensagens em cenários de concorrência alta, onde múltiplas mensagens podem ser enviadas quase simultaneamente. A garantia de entrega ordenada das notificações é fundamental para manter a consistência do sistema, e entender como o PON-BEAM aborda esse desafio pode oferecer insights valiosos para aplicações críticas. Qual é o mecanismo utilizado pelo PON-BEAM para garantir a ordem correta de processamento das notificações em tais cenários?