Esta pesquisa compara sistemas de implementação semelhantes ao Ethereum e analisa as dificuldades e possibilidades de realizar a execução paralela de transações.
Vale ressaltar que as cadeias analisadas para esta pesquisa são baseadas no esquema de design do modelo de conta, não incluindo o esquema UTXO.
FISCO-BCOS, um dos blockchains do consórcio que suporta execução paralela de verificação de transações dentro de blocos.
Khipu public chain, implementação scala do protocolo Ethereum.
Cadeia pública Aptos, Move Virtual Machine.
Vamos dar uma olhada no processo tradicional de execução de transações.
O módulo de execução retira cada transação do bloco e a executa sequencialmente.
O estado do mundo mais recente será modificado durante o processo de execução e, em seguida, o estado será adicionado após a conclusão de uma transação para atingir o estado do mundo mais recente após a conclusão do bloco.
A execução do próximo bloco é estritamente dependente do estado mundial do bloco atual/anterior, portanto, esse processo de execução sequencial de thread único não é muito adequado para execução paralela.
Abaixo, estão os principais conflitos nos atuais métodos de execução paralela do Ethereum:
Conflito de armazenamento do mesmo endereço : onde ambos os contratos modificaram o armazenamento da mesma variável global.
Conflito de chamada entre contratos: se o contrato A for implantado primeiro, o contrato B precisa aguardar até que a implantação do contrato A seja concluída para chamar o contrato A. No entanto, quando as transações são paralelas, não há essa sequência, o que leva ao conflito.
O FISCO-BCOS 2.0 utiliza uma estrutura gráfica no processamento de transações. Os desenvolvedores projetaram um Parallel Transaction Executor (PTE) baseado no modelo Directed Acyclic Graph (DAG).
O PTE pode ajudá-lo a utilizar totalmente as vantagens de um processador multi-core para que as transações no bloco possam ser executadas em paralelo na medida do possível.
Ao mesmo tempo, fornece uma interface de programação simples e amigável para o usuário, para que o usuário não precise se preocupar com os tediosos detalhes da implementação paralela.
Os resultados experimentais do programa de teste de benchmark mostram que, em comparação com o esquema tradicional de execução de transação serial, o PTE rodando em um processador de 4 núcleos sob condições ideais pode atingir cerca de 200% ~ 300% de melhoria de desempenho, e a melhoria computacional é proporcional ao número de núcleos.
Quanto mais núcleos, melhor o desempenho.
Um grafo direcionado acíclico é muitas vezes referido como gráfico acíclico direcionado (DAG).
Em um lote de transações, são identificados os recursos mutuamente exclusivos ocupados por cada transação; então um DAG dependente de transação é construído de acordo com a sequência de transações no bloco e a relação de ocupação de recursos mutuamente exclusivos.
Conforme mostrado na figura abaixo, todas as transações com um grau de entrada de 0 (sem tarefas de pré-encomenda dependentes) podem ser executadas em paralelo. A transação DAG à direita pode ser obtida por classificação topológica com base na ordem da lista de transações original à esquerda.
Pegue todas as transações no bloco do bloco compactado.
Inicialize uma instância DAG com o número de transações como o número máximo de vértices.
Leia todas as transações em ordem. Se uma transação puder ser mesclada, resolva seu campo de conflito e verifique se alguma transação anterior está em conflito com ela. Em caso afirmativo, construa uma borda de dependência entre as transações correspondentes. Se a transação não for mescável, considera-se que ela deve ser executada após todas as transações anteriores terem sido executadas, então uma borda de dependência é criada entre a transação e todas as suas predecessoras.
Nota : Uma vez criadas todas as arestas dependentes, elas não podem ser mescladas e podem ser executadas apenas sequencialmente.
O thread principal inicializará primeiro um pequeno grupo de threads com base no número de núcleos de hardware e, se os núcleos de hardware falharem, nenhum outro thread será criado.
Quando o DAG não é concluído, o loop de encadeamento espera que a transação pronta com o grau de entrada 0 seja retirada do método waitPop do DAG. Se a transação a ser executada for realizada com sucesso, a transação será executada. Se falhar, o DAG concluiu a execução e o encadeamento é encerrado.
O FISCO BCOS verifica se os triplos, ou seja, raiz de estado, raiz de transação e raiz de recebimento, são iguais entre si para determinar se os estados são acordados. Uma raiz de transação é um valor de hash calculado com base em todas as transações no bloco.
Desde que todos os nós de consenso processem os mesmos dados de bloco, a raiz da transação deve ser a mesma, o que é relativamente fácil de garantir. A chave é garantir que o estado e a raiz do recebimento gerados após a transação sejam os mesmos.
É sabido que a ordem de execução entre as instruções executadas em paralelo em diferentes núcleos de CPU não pode ser prevista com antecedência, e o mesmo vale para transações executadas em paralelo.
No esquema de execução de transação tradicional, a raiz do estado muda uma vez que cada transação é executada e a raiz do estado alterada é gravada no recibo da transação.
Depois que todas as transações são executadas, a raiz do estado final representa o estado atual do blockchain. Ao mesmo tempo, uma raiz de recebimento é calculada com base em todos os recebimentos de transações.
Pode-se ver que na implementação tradicional, a raiz do estado atua como uma variável global compartilhada.
Quando as transações são executadas em paralelo e fora de ordem, o cálculo tradicional da raiz do estado não é mais aplicável porque as transações são executadas em uma ordem diferente em máquinas diferentes e a raiz do estado final não é garantida como consistente, nem a raiz do recebimento é garantida para ser consistente.
No FISCO BCOS, as transações são executadas primeiro em paralelo e o histórico da mudança de estado de cada transação é registrado. Depois que todas as transações são executadas, uma raiz de estado é calculada com base no histórico.
Ao mesmo tempo, a raiz do estado na confirmação da transação torna-se a raiz do estado final após todas as transações terem sido executadas, garantindo assim que os nós de consenso ainda possam chegar a um acordo, mesmo que as transações sejam executadas em paralelo.
Se duas transações não forem dependentes, mas forem consideradas, isso levará a uma perda de desempenho desnecessária. Por outro lado, se ambas as transações reescreverem o estado da mesma conta, mas forem executadas em paralelo, o estado final da conta pode ser incerto.
Portanto, a determinação da dependência é uma questão importante que afeta o desempenho e pode até determinar se o blockchain pode funcionar corretamente.
Em uma transação de transferência simples, podemos julgar se duas transações são dependentes com base nos endereços do remetente e do destinatário. Tome como exemplo as três transações de transferência a seguir, A→B, C→D e D→E.
É fácil ver que a transação D→E depende do resultado da transação C→D, mas a transação A→B não tem nada a ver com as outras duas transações, portanto pode ser executada em paralelo.
Esse tipo de análise é verdadeiro em um blockchain que suporta apenas transferências simples, mas pode não ser tão preciso em um blockchain Turing-completo que executa contratos inteligentes, porque não sabemos exatamente o que está acontecendo em um contrato de transferência escrito pelo usuário. . Aqui está o que pode acontecer.
Parece que a transação de A→B não tem nada a ver com o status da conta de C e D, mas na implementação subjacente do usuário, A é uma conta especial e uma determinada taxa deve ser deduzida da conta de C para todo dinheiro transferido pela conta de A.
Neste cenário, as três transações estão todas relacionadas, portanto não podem ser executadas em paralelo. Se as transações forem divididas de acordo com o método de análise de dependência anterior, isso poderá causar erros.
Podemos deduzir automaticamente quais dependências realmente existem em uma transação com base no conteúdo do contrato do usuário? A resposta é não. Conforme mencionado anteriormente, é difícil analisar as dependências contratuais e o processo de execução na análise estática.
No FISCO BCOS, a atribuição de dependências comerciais é deixada para os desenvolvedores mais familiarizados com o conteúdo do contrato. Especificamente, os recursos mutuamente exclusivos dos quais a transação depende podem ser representados por um conjunto de strings.
O FISCO BCOS expõe a interface para o desenvolvedor, que define os recursos dos quais a transação depende na forma de strings e informa o executor da cadeia.
O executor organizará automaticamente todas as transações no bloco na transação DAG de acordo com as dependências de transação especificadas pelo desenvolvedor.
Por exemplo, em um contrato de transferência simples, o desenvolvedor simplesmente especifica que a dependência para cada transação de transferência é {endereço do remetente + endereço do destinatário}.
Além disso, se o desenvolvedor introduzir outro endereço de terceiros na lógica de transferência, a dependência precisará ser definida como {endereço do remetente + endereço do destinatário + endereço de terceiros}.
Essa abordagem é intuitiva, simples e geral e se aplica a todos os contratos inteligentes, mas também aumenta a responsabilidade sobre os ombros do desenvolvedor.
O desenvolvedor deve ter muito cuidado ao especificar as dependências da transação. Se as dependências não forem escritas corretamente, as consequências serão imprevisíveis.
Para que os desenvolvedores usem a estrutura de contratos paralelos, o FISCO BCOS definiu algumas especificações para a redação de contratos. As especificações são as seguintes:
Se duas transações podem ser executadas em paralelo depende se as duas transações são mutuamente exclusivas. A exclusão mútua refere-se à interseção do conjunto de variáveis de armazenamento de duas transações.
Por exemplo, em um cenário de transferência de ativos, uma transação é uma operação de transferência entre usuários. transfer(X, Y) representa a interface de transferência do usuário X para o usuário Y, e a exclusão mútua é a seguinte.
Parâmetro mutuamente exclusivo: Parâmetro relacionado à operação “ler/escrever” da variável de armazenamento do contrato na interface do contrato. Pegue a interface de transferência transfer(X, Y) por exemplo. X e Y são parâmetros mutuamente exclusivos.
Mutex: O conteúdo mutex específico extraído de uma transação de acordo com os parâmetros mutex. Pegue a interface de transferência transfer(X, Y) por exemplo. Em uma transação de transferência usando esta interface, o parâmetro específico é transfer(A, B) e o mutex desta operação é [A, B]. Para outra transação, transfer(A, C) é chamado e o mutex para esta operação é [A, C].
Determinar se duas transações podem ser executadas em paralelo ao mesmo tempo é determinar se o mutex de duas transações se cruzam. As transações cujas interseções estão vazias podem ser executadas em paralelo.
O FFISCO-BCOS fornece duas maneiras de escrever contratos paralelos, contratos pré-compilados e contratos de solidez, apenas os últimos dos quais são descritos aqui. O mesmo vale para contratos pré-compilados.
Para escrever um contrato de solidity paralelo, além disso, simplesmente faça ParallelContract.sol a classe base para os contratos que você deseja paralelizar. O método registerParallelFunction() é chamado para registrar interfaces que podem ser paralelizadas.
O código do Contrato Paralelo é o seguinte:
pragma solidity ^0.4.25; //Precompile the contract interface contract ParallelConfigPrecompiled { function registerParallelFunctionInternal(address, string, uint256) public returns (int); function unregisterParallelFunctionInternal(address, string) public returns (int); } //The parallel contract base class needs to be registered and the subcontract needs to be implement enable or disable interface contract ParallelContract { ParallelConfigPrecompiled precompiled = ParallelConfigPrecompiled(0x1006); function registerParallelFunction(string functionName, uint256 criticalSize) public { precompiled.registerParallelFunctionInternal(address(this), functionName, criticalSize); } function unregisterParallelFunction(string functionName) public { precompiled.unregisterParallelFunctionInternal(address(this), functionName); } function enableParallel() public; function disableParallel() public; }
O exemplo a seguir é um contrato de transferência escrito com base em um contrato-quadro paralelo:
pragma solidity ^0.4.25; import "./ParallelContract.sol"; // Introduce ParallelContract.sol contract ParallelOk is ParallelContract // useParallelContract as a base class { // Contract implementation mapping (string => uint256) _balance; // Global mapping // The mutually exclusive variables from and to are the first two parameters at the beginning of transfer (). It can be seen that the contract requirements are still very strict, which will make users uncomfortable to write function transfer(string from, string to, uint256 num) public { _balance[from] -= num; // From is the key of the global mapping, and is a mutually exclusive parameter _balance[to] += num; //// To is the key of the global mapping, and is a mutually exclusive parameter } // The mutex variable name comes first as an argument to the beginning of set() function set(string name, uint256 num) public { _balance[name] = num; } function balanceOf(string name) public view returns (uint256) { return _balance[name]; } // Register contract interfaces that can be parallel function enableParallel() public { // The function definition string (note that there are no Spaces after ",") and the first few arguments are mutex arguments (mutex arguments must be first when designing a function) //The number 2 indicates that the first two are mutex parameters, and the system decodes the mutex according to the function signature and abi registerParallelFunction("transfer(string,string,uint256)", 2); // critical: string string // registerParallelFunction("set(string,uint256)", 1); // critical: string } // Deregister the parallel contract interface function disableParallel() public { unregisterParallelFunction("transfer(string,string,uint256)"); unregisterParallelFunction("set(string,uint256)"); } }
Uma interface de contrato paralela deve satisfazer:
Antes de programar uma interface, determine os parâmetros mutuamente exclusivos da interface. Os parâmetros mutuamente exclusivos da interface são mutuamente exclusivos para variáveis globais. As regras para determinar parâmetros mutuamente exclusivos são as seguintes:
Por exemplo, existem múltiplas variáveis globais de tipos simples no contrato e diferentes interfaces acessam diferentes variáveis globais.
Se você deseja paralelizar diferentes interfaces, precisa definir um parâmetro mutex no parâmetro de interface com a variável global modificada para indicar qual variável global é usada durante a chamada.
Quando chamado, o parâmetro mutex recebe ativamente o “nome da variável” modificado da variável global para identificar o mutex da transação.
Tal como: Se setA(int x)
modifica globalA
como um parâmetro global, setA
precisa ser definido como set(string aflag, int x)
. Quando chamado, setA("globalA", 10)
é passado. Use o nome da variável “globalA”
para indicar que o mutex para esta transação é globalA
.
Depois de determinar os parâmetros mutuamente exclusivos, determine o tipo e a ordem do parâmetro de acordo com as regras. As regras são as seguintes:
Percebe-se que a transação paralela do FISCO-BCOS depende muito das especificações dos contratos redigidos pelos usuários.
Se as especificações dos contratos escritos pelos usuários não forem padronizadas, o sistema realiza execuções paralelas às pressas, o que pode causar a inconsistência raiz dos livros contábeis.
Khipu acredita que não é realista para os usuários identificar e rotular o intervalo de endereços que criarão conflitos estáticos no momento da redação do contrato sem erros. Isso contrasta com a visão do FISCO-BCOS.
Se, onde e sob quais condições a condição de corrida aparecerá, pode ser julgado apenas quando a aquisição de certeza envolve o estado atual.
Esse tipo de julgamento, com as linguagens de programação de contrato atuais, torna quase impossível para a análise estática do código obter resultados completamente corretos e infalíveis.
Khipu fez uma tentativa mais abrangente de resolver esse problema e concluiu um processo para implementá-lo.
No Khipu, cada transação no mesmo bloco começa no estado mundial do bloco anterior e, em seguida, é executada em paralelo, registrando as três condições de corrida acima encontradas ao longo de todos os caminhos de experiência ideais durante a execução.
Após a fase de execução paralela vem a fase de mesclagem, quando os estados do mundo paralelo são mesclados um a um. Ao mesclar uma transação, primeiro julgue se você tem um conflito com as condições de corrida mescladas anteriormente a partir das condições estáticas registradas.
Se não, mescle diretamente. Se assim for, a transação é executada novamente começando com o estado anterior do mundo que foi mesclado.
O último estado do mundo mesclado é verificado no hash do bloco. Esta é a última linha de defesa. Se a verificação estiver incorreta, a mesclagem anterior é abandonada e o bloco é executado novamente.
Aqui, Khipu introduz um índice de paralelismo, que se refere à proporção de transações em um bloco que podem combinar resultados diretamente sem precisar ser executados novamente.
A observação de Khipu do replay do Ethereum ao longo de vários dias, desde o bloco de criação até o bloco mais recente, mostra que essa proporção (paralelismo) pode atingir 80% em média.
Em geral, se as tarefas de computação puderem ser totalmente paralelizadas, a escalabilidade de uma única cadeia é infinita. Porque você sempre pode adicionar mais núcleos de CPU a um nó. Se não for esse o caso, a taxa teórica máxima é limitada pelo teorema de Andal:
O limite para o qual você pode acelerar o sistema depende do recíproco das partes que não podem ser paralelizadas. Portanto, se você pode paralelizar 99%, pode acelerar até 100 vezes. Mas se você conseguir atingir apenas 95% de paralelização, poderá obter apenas 20 vezes mais rápido.
De todas as transações no Ethereum, cerca de 80% podem ser paralelizadas e 20% não, então o limite de velocidade de Khipu é de cerca de 5 vezes.
Ao entender as instruções no código evm, descobriu-se que um número limitado de instruções criou processos de leitura e gravação para o armazenamento, portanto, foi possível gravar esses processos de leitura e gravação para formar uma coleção de leitura e gravação, mas o código estático análise não pôde garantir que esses processos fossem registrados.
Portanto, é necessário pré-executar cada transação uma vez ao processar cada bloco. O processo de pré-execução informa se as transações são lidas e gravadas na mesma conta ou armazenamento e cria um readSet e um writeSet para cada transação.
Se houver 100 transações no blockchain, essas 100 transações podem ser executadas em paralelo por meio do pool de threads. Cada contrato tem o mesmo estado inicial do mundo e 100 readSets e writeSets serão criados durante a execução, bem como 100 novos estados cada.
Terminada a pré-execução, inicia-se a próxima etapa do processamento. Idealmente, se as 100 entradas readSet e writeSet não forem conflitantes, elas podem ser mescladas diretamente para produzir o estado final do mundo de todas as transações no bloco. No entanto, a transação muitas vezes não é tão ideal.
A forma correta de lidar com isso é comparar o readSet e o writeSet após a execução da primeira transação com o readSet e o writeSet após a execução do segundo contrato, e ver se eles leram e escreveram na mesma conta ou armazenamento.
Se assim for, isso significa que os dois acordos estão em conflito. Em seguida, a segunda transação começará após a conclusão da primeira transação e será executada novamente.
Da mesma forma, à medida que a máquina de estado de mesclagem continua, o conjunto de conflitos continuará a se acumular e, desde que as transações subsequentes entrem em conflito com as transações anteriores, elas serão executadas sequencialmente até que todas as transações tenham sido executadas.
Através da reprodução de transações na rede principal do Ethereum, verifica-se que onde há muitos conflitos, a maioria dos casos são trocas no mesmo bloco com transações inter-relacionadas, o que também é consistente com esse processo.
Aptos é construído na linguagem Move de Diem e MoveVM para criar uma cadeia de alto rendimento que permite a execução paralela. A abordagem da Aptos é detectar associações enquanto é transparente para usuários/desenvolvedores.
Ou seja, as transações não precisam declarar explicitamente qual parte do estado (localização da memória) elas usam.
A Aptos usa uma versão modificada da memória de transação de software chamada Block-STM e implementa seu mecanismo de execução paralela baseado no Block-STM .
O Block-STM usa MVCC (Multi-version Concurrency Control) para evitar conflitos de gravação e gravação. Todas as gravações no mesmo local são armazenadas com suas versões, que contêm seu TX-ID e o número de vezes que a gravação tx foi reexecutada.
Quando uma transação (tx) lê um valor para um local de memória, ela obtém o valor gravado do MVCC para esse local que ocorreu antes de tx junto com a versão associada para determinar se há um conflito de leitura/gravação.
No Block-STM, as transações são pré-classificadas em blocos e divididas entre os threads do processador para execução paralela durante a execução. Na execução paralela, assume-se que não há dependências para executar a transação.
Os locais de memória modificados pela transação são registrados. Após a execução, verifique todos os resultados da transação. Durante a validação, se for encontrada uma transação para acessar um local de memória modificado por uma transação anterior, a transação é inválida.
Atualize o resultado da negociação e execute-a novamente. Este processo é repetido até que todas as transações do bloco tenham sido executadas. O Block-STM acelera a execução quando vários núcleos de processador são usados. A aceleração depende de quão interdependentes são as transações.
Pode-se observar que o esquema utilizado pelo Aptos é mais ou menos semelhante ao Khipu citado acima, mas existem algumas diferenças na implementação, que detalhamos a seguir:
A Aptos fez um benchmark correspondente após a integração do bloco-STM e comparou entre a execução sequencial e a execução paralela de um bloco de 10k transações. O resultado da comparação é mostrado a seguir:
Pode ser visto na figura acima que Block STM atinge 16 vezes mais rápido que a execução sequencial com 32 threads em paralelo e mais de 8 vezes mais rápido sob alta contenção.
Com base na comparação e análise acima, pode-se concluir que alguns esquemas exigem que os usuários gravem o armazenamento de acordo com as regras estabelecidas ao escrever contratos para que as dependências possam ser encontradas por análises estáticas e dinâmicas.
Solana e Sui usam esquemas semelhantes, mas a percepção do usuário é diferente. Este esquema é essencialmente uma mudança no modelo de armazenamento para obter melhores resultados de análise.
Khipu e Aptos são esquemas independentes de usuário. A sobrecarga da execução paralela não recai sobre os desenvolvedores e eles não precisam pensar nisso ao redigir seus contratos.
A máquina virtual analisa dinamicamente as relações de dependência antes da execução, implementando assim a execução paralela sem relações de dependência.
Isso é difícil de implementar e o grau de paralelismo depende até certo ponto da divisão de contas da transação. Quando há muitos conflitos de transação, o desempenho se deteriora significativamente devido à reexecução constante.
A Aptos mencionou que fará otimizações futuras de contratos de autoria do usuário para analisar melhor as dependências e, assim, obter uma execução mais rápida.
A simples modificação de um esquema baseado em série para um esquema paralelo pode trazer uma melhoria de 3 a 16 vezes na taxa de transferência transacional em um ambiente de cadeia pública e, se isso puder ser combinado com grandes blocos e grandes limites de gás, a taxa de transferência L2 será ainda mais otimizada, potencialmente cerca de 100 vezes.
Do ponto de vista da engenharia, em relação à implementação e eficiência, o OlaVM provavelmente adotará o esquema Khipu mais uma solução de modelo de armazenamento personalizado, que pode melhorar o desempenho, evitando a complexidade causada pela introdução do Block-STM e facilitando uma melhor otimização de engenharia.
Fundada em 2021 e desenvolvida por desenvolvedores de blockchain de alto nível, a Sin7y é uma incubadora de projetos e uma equipe de pesquisa de tecnologia blockchain que explora as tecnologias mais importantes e de ponta, incluindo EVM, Layer2, cadeia cruzada, computação de privacidade, soluções de pagamento autônomo, etc. .
Atualmente, estamos trabalhando em um ZKVM compatível com EVM, rápido e escalável chamado OlaVM. Se você estiver interessado em falar conosco, sinta-se à vontade para se juntar ao nosso grupo TG ou envie um e-mail para [email protected] .