Infra-Estrutura de Software – Engenharia da Computação Lista de POSIX Threads – 2013.2 Leia com atenção as seguintes considerações: 1. Esta lista contém de 7 questões de programação em POSIX Threads (PThreads). Dentre estas, algumas questões poderão conter também uma parte teórica. 2. A lista deverá ser respondida individualmente ou em dupla. Se qualquer tentativa de cópia for identificada, todos os envolvidos terão suas listas zeradas. 3. As respostas das questões de programação deverão ser enviadas através do site da disciplina (www.cin.ufpe.br/~if677ec) na seção “Listas”, até a data limite estipulada. As questões devem, também, estar organizadas em arquivos separados, um por questão, com o nome no formato “Q[número da questão].c” ou “Q[número da questão].cpp”, sem aspas. Questões com o nome diferente deste formato não serão aceitas. 4. As duas seguintes condições devem ser cumpridas para que a lista seja aceita: - Pelo menos 1 das seguintes questões deve ser feita: Q6 e Q7. - Pelo menos 2 das seguintes questões devem ser feitas: Q3, Q4 e Q5. 1 5. Em caso de dúvidas, envie um email para [email protected] (somente monitores) ou [email protected] (monitores e alunos). QUESTÕES: Q1. Eleição Moderna Uma cidade começou a usar um novo sistema eletrônico de votação para as eleições de prefeito. Após o período de votação, os votos de cada bairro estão em um arquivo e precisam agora ser contabilizados. Faça um programa que receba um número N de arquivos e um número T <= N de threads utilizadas para fazer a contagem e um número C de candidatos a prefeito. Após isso o programa deverá abrir os N arquivos nomeados “x.in” onde 1 <= x <= N. Cada arquivo terá 1 voto por linha que será um número y | 0 <= y <= C em que 0 significa voto em branco, 1 significa voto ao candidato 1 e assim sucessivamente. Ao final imprima na tela o total de votos, a porcentagem de votos recebidos por cada candidato (e dos em branco também) e o número do candidato vencedor (que será o candidato com mais votos não importando a porcentagem). Exemplo1: ENTRADA: N = 2; C = 3; T = 2 Aquivo “1.in”: 0 1 0 2 3 3 Aquivo “2.in”: 0 1 0 2 2 3 3 2 2 2 2 SAÍDA: Total de votos: 16 Percentual de votos em branco: 25% Percentual de votos ao candidato 1: 12.5% Percentual de votos ao candidato 2: 37.5% Percentual de votos ao candidato 3: 25% Candidato 2 é o Vencedor. Q2. Merge sort e Quicksort Um dos problemas computacionais mais antigos é o de ordenação. Há muito tempo estuda-se uma maneira eficiente de ordenar uma sequência de dados de diversos tipos, sendo os mais comuns números e ordem alfabética. Disso nasceram os algoritmos de ordenação, que são vários: Bubble, Insert, Gnome, etc. Dois desses nos interessam em particular: Merge sort e Quicksort. Esses dois algoritmos usam o princípio do “Divide to conquer”, que consiste em dividir o problema em partes menores para maximizar a eficiência. Nossa ideia é tornar esses dois algoritmos ainda mais eficientes, dividindo-os em execuções paralelas para um computador com até 2, 4 ou 8 cores (threads abertas do inicio ao fim da execução) e observem o comportamento. Evitem espera ocupada. Façam também uma versão do programa que não tenha controle do número máximo de threads, e observem o tempo de execução entre as versões. Comente brevemente a diferença e justifique. Q3. #pragma omp for Essa questão tem uma parte prática e outra teórica. A parte prática deve ser enviada sem main, apenas a 3 implementação da função pedida (Com tudo o que for necessário para que esta funcione: Outras funções, estruturas, variáveis globais e bibliotecas de acordo com o que jugar necessário). A parte teórica deve ser um comentário em bloco deixando claro que diz respeito à parte teórica e não explicação do código, no começo do código. Essa parte vale 30% da nota da questão e pode ser feita independentemente do código, apesar de ser recomendado a fazê-la depois da implementação. OMP (Open MP) é uma API de C e Fortran muito usada para paralelismo cientifico, ela provê implementações muito sucintas, mas poderosas. Um simples #pragma omp parallel antes de chaves faz com que todas aquelas linhas sejam executas repetidas vezes por N threads (N definido por variável de ambiente), ainda mais simples um #pragma omp for parralel antes de um for faz com que esse laço seja paralelizado de tal forma que cada iteração só ocorra uma vez, mas que todas ocorram até o final do for em qualquer ordem. OMP possui muitos outros recursos, mas em si não passa de um padrão. Suas implementações são dependentes da máquina, compilador de C ou Fortran, Sistema operacional e N outras variantes. OMP é até implementado usando Pthreads às vezes (o que é fazer um padrão de padrões, já que a pthread ainda terá outra implementação mais baixo nível). Então sua missão é implementar uma função em C que simule um #pragma omp for parallel em pthread. Para isso sabemos que OMP é uma API inteligente e não gera threads desnecessárias, nem fecha e depois abre outras threads sem motivo. Dentro de suas variáveis de ambiente há uma variável chamada OMP_NUM_THREADS que será aqui emulada por uma macro de mesmo nome. Essa variável costuma ter o mesmo número da quantidade de núcleos que o sistema possui afim de usar completamente a maquina, mas sem gerar overheads desnecessários. Sendo assim quando um for aparece no código, OMP entrega a cada uma das N threads uma parcela do trabalho daquele for. E espera que todas as N threads acabem seu trabalho. Como exemplo temos : #pragma omp for parallel for(int i = 0 ; i < 10 ; i++) { CODIGO... } 4 Se o número de threads a ser criada é 4, OMP destribuiria o trabalho possivelmente assim: Thread 0: for(int i = 0; i Thread 1: for(int i = 3; i Thread 2: for(int i = 6; i Thread 3: for(int i = 8; i Espera as threads acabarem. < < < < 3 6 8 10 ; ; ; ; i++) i++) i++) i++) CODIGO... CODIGO... CODIGO... CODIGO... Repare que nenhum trabalho é realizado mais de uma vez, e que nenhuma thread pegou mais trabalho que a outra de forma considerável. Nenhuma trabalho não pedido também foi distribuído (como uma implementação errada poderia fazer as Threads 2 e 3 terem também 3 iterações, o que transformaria o for entre 0 e 12! Um absurdo) No final OMP esperaria que todas as 4 threads acabassem para continuar. De fato como OMP divide o trabalho não é tão rígido, e é ai que você entra. Sabemos que #omp for parallel possui uma clausula chamada schedule (definida por padrão quando omitida) que recebe dois argumentos o tipo de escalonamento das threads e o tamanho do chunk_size. Os tipos de escalonamento são: static, dynamic, guided, runtime, auto. O chunk_size é um valor entre 1 e o número total de iterações. O chunk_size é o quanto as iterações devem estar juntas, exemplo com chunk_size igual a 3 as iterações são passadas de 3 em 3 para as threads, respeitando o final das iterações. No primeiro exemplo desta questão o chunk_size era igual a 3, mesmo assim as ultimas threads pegaram 2 iterações cada ( o mais próximo de 3 possível ). Segue agora as definições dos schedules: static: Antes de todas as threads rodarem já é definido quais iterações cada um vai executar e não há nenhuma comunicação entre a thread pai e as filhas até elas terminarem. As iterações são passadas em round-robin a cada chunk_size. Ex.1.: Um for entre 0 e 100(exclusivo) com chunk_size igual a 10 e 4 threads seria divido (deterministicamente): Thread Thread Thread Thread 0: 1: 2: 3: 0-9, 40-49, 80-84 10-19, 50-59, 85-89 20-29, 60-69, 90-94 30-39, 70-79, 95-99 5 Ex.2: Um for entre 0 e 100(exclusivo) com chunk_size igual a 20 e 4 threads seria divido (deterministicamente): Thread Thread Thread Thread 0: 1: 2: 3: 0-19, 80-84 20-39, 85-89 40-59, 90-94 60-79, 95-99 dynamic: As threads são inicializadas sem iterações definidas e conforme vão necessitando elas pedem mais iterações. Deve ser passado tantas iterações quanto sejam definidas em chunk_size na ordem original do for desde que não se ultrapasse o limite definido no for, caso não haja mais trabalho a thread deve ser fechada. Reparem que condições de corrida são implícitas nesse modelo. Ex.1.: Um for entre 0 e 15(exclusivo) com chunk_size igual a 2 e 4 threads será executado possivelmente assim(dependendo de condições de corrida): Thread Thread Thread Thread Thread Thread Thread Thread Thread Thread Thread Thread Thread 0 0 2 3 1 3 3 0 2 1 2 3 0 a 3 são criadas. pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: É fechada. pede iterações: É fechada. pede iterações: É fechada. pede iterações: É fechada. 0-1 2-3 4-5 6-7 8-9 10-11 12-13 14 Ex.2.: Um for entre 0 e 20(exclusivo) com chunk_size igual a 4 e 4 threads seria executado possivelmente assim (dependendo de condições de corrida): Thread Thread Thread Thread Thread Thread Thread Thread Thread Thread 0 0 1 3 2 1 0 2 3 1 a 3 são criadas. pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: Recebe iterações pede iterações: É fechada. pede iterações: É fechada. pede iterações: É fechada. pede iterações: É fechada. 0-3 4-7 8-11 12-15 16-19 6 Além dessas duas tecnicas de schedule OMP suporta também guided, auto e runtime que não serão cobradas nesta questão. Sendo assim a parte prática é implementar a função em C/C++ com a seguinte assinatura: void omp_for(int inicio , int passo , int final , int schedule , int chunk_size , void (*f)(int)); Onde simularia um: #pragma omp for for(int i = inicio ; i < final ; i += passo) { f(i); } Repare que f é um ponteiro de função e schedule deve receber um entre vários tipos enumerados ou macros para cada uma das 2 politicas de escalonamento. Parte teórica (Valendo 30% da questão, com ou sem a implementação): Discuta brevemente as diferenças entre o desempenho das diferentes schedules static e dynamic em desempenho e em quais aplicações cada uma é mais adequada. Discuta também o impacto da escolha correta do tamanho das chunks_sizes e quais seriam boas escolhas para diversas aplicações. Recomendo o fazer depois de testes da sua própria implementação desta questão, ou em teste usando OMP. Adicionalmente você pode comparar sua implementação com OMP. Q4. N-ésimo Número Primo Crie um programa em pthreads que encontre o N-ésimo número primo natural. Uma técnica simples para determinar se um certo número natural é primo é a “divisão por tentativa”. Basta dividi-lo por todos os números menores (Exceto pelo número 1) ou iguais à sua raiz quadrada. Se nenhuma dessas “tentativas” resultar em uma divisão exata (Divisão cujo resto é igual a 0) o número é considerado primo. Caso contrário ele é considerado composto. O número 1 não é considerado um número primo e nem composto. O usuário deverá informar pelo teclado o número T de threads a serem criadas e o número N. As T threads só poderão ser criadas no começo do programa e só poderão ser encerradas depois que o número primo N já tenha sido determinado. Cada thread deverá fazer várias divisões por tentativas em números diferentes. Assim que uma 7 thread terminar de testar as divisões em um número, ela imediatamente deverá testar em outro. O número procurado deverá ser impresso no console da tela. Observação: Nessa questão a sincronização entre as threads é algo fundamental. Um programa que não esteja perfeitamente sincronizado poderá determinar não o N-ésimo número primo, mas sim um número primo próximo a ele. Dica: O único número primo par é o número 2 e todos os outros são múltiplos dele. Para efeitos práticos, então, evite utilizar esses números tanto para testá-los quanto para testar outros números. Exemplo1.: ENTRADA: Numero T: 1 Numero N: 5 SAÍDA: Exemplo2.: ENTRADA: N-esimo numero primo: 11 Numero T: 4 Numero N: 34156 SAÍDA: N-esimo numero primo: 404021 Exemplo3.: ENTRADA: Numero T: 8 Numero N: 89336 SAÍDA: N-esimo numero primo: 1150489 Q5. Matrizes Esparsas Uma matriz esparsa é uma matriz em que a maioria de seus elementos tem valor zero. Matrizes desse tipo aparecem frequentemente em aplicações científicas. Ao contrário de uma matriz densa, em que é necessário levar em consideração todos os valores da matriz, em uma matriz esparsa podemos nos aproveitar da estrutura da matriz para acelerar a computação de resultados. Muitas vezes, faz-se mesmo necessário aproveitar essa estrutura, ao se lidar com matrizes esparsas de dimensões imensas (da ordem de 10^6 x 10^6) para que a computação termine em um tempo razoável, visto que a multiplicação de matrizes tradicional tem complexidade O(n^3). 8 Nesta questão, você deverá implementar algoritmos para realizar algumas operações comuns sobre matrizes esparsas de números de ponto-flutuante. Para auxiliar na definição de uma matriz esparsa, definiremos primeiramente um vetor esparso: Um vetor esparso será dado por um vetor de pares (índice,valor), no qual o primeiro elemento do par indica o índice de um elemento nãozero do vetor, e o segundo elemento indica seu valor. Portanto, o vetor {0,0,0,1.0,0,2.0}, em forma de vetor esparso, será representado por {Par (3,1.0),Par (5,2.0)} A implementação dos vetores esparsos fica a cargo do aluno. Uma matriz esparsa será dada por um vetor de vetores esparsos. Portanto, a matriz esparsa a seguir: 2.0 -1.0 0.0 0.0 -1.0 2.0 -1.0 0.0 0.0 -1.0 2.0 -1.0 0.0 0.0 -1.0 2.0 Seria representada como: {{(0,2.0),(1,-1.0)}, {(0,-1.0),(1,2.0),(2,-1.0)}, {(1,-1.0),(2,2.0),(3,-1.0)}, {(2,-1.0),(3,2.0)}} O aluno deve implementar as seguintes operações sobre matrizes esparsas: - Multiplicação de uma matriz esparsa por um vetor denso (vetor comum de C) - Multiplicação de uma matriz esparsa por outra matriz esparsa - Multiplicação de uma matriz esparsa por uma matriz densa (matriz comum de C) Todas essas funções devem usar paralelismo, tanto na operação de produto interno (que deve dividir os vetores envolvidos de forma que cada thread processe uma parte do vetor) quanto na multiplicação (em que cada linha da matriz deve ser gerenciada por uma thread). Portanto, deve-se ter uma thread para cada linha da matriz, que, por sua vez, lance várias threads que realizem a operação de produto interno. Para que as funções que multiplicam vetores esparsos por densos sejam executadas de forma eficiente, é importante notar que 9 dividir o vetor denso em partes iguais leva a operações desnecessárias. Portanto, o aluno deve implementar a lógica da questão de forma tal que cada thread receba uma parte de tamanho diferente do vetor denso, levando ao menor número de operações que o aluno consiga implementar. A eficiência da implementação será levada em consideração, além da corretude da questão e da ausência de deadlocks, livelocks, e starvations. Q6. Grafos Bicoloríveis Escreva um programa que determine se um dado grafo conexo e bidirecional é bicolorível ou não. Um grafo é bicolorível se existir uma maneira de o colorir somente com duas cores na qual vértices adjacentes não sejam da mesma cor. Rodando uma busca em largura a partir de uma raiz qualquer no grafo se deve criar uma nova thread para cada novo vértice ainda não visitado, enquanto a thread pai deverá obrigatoriamente dormir enquanto espera as threads filhas terminarem de colorir o grafo. No final da execução deve ser aberta uma e somente uma thread por nó no grafo. Cada thread marcará o seu vértice como visitado e tentará o colorir com cor diferente do pai, se ele for a raiz do grafo uma cor arbitraria deve ser escolhida. Caso uma thread tente colorir um vértice que já foi visitado com uma cor diferente da que ele tem no momento o programa já pode retornar erro, pois não há como bicolorir esse grafo. Caso se consiga bicolorir o grafo completamente o programa deverá informar que o grafo é bicolorível. Os grafos de entrada serão obrigatoriamente conexos. Serão passados por um arquivo de entrada com o nome “Entrada.txt” o número V de vértices, seguido do número A de arestas e, consequentemente, de A arestas. Exemplo1.: ENTRADA: 11 13 12 1 11 23 2 10 38 45 58 59 10 SAÍDA: 5 11 6 11 7 11 9 10 10 11 E bicolorivel. Exemplo2.: ENTRADA: 10 15 12 13 1 10 26 24 37 35 45 48 59 67 69 78 8 10 9 10 SAÍDA: Nao e bicolorivel. Q7. Superescalar de Vetores - SupVet® Processadores modernos de uso geral são costumeiramente superescalares, ou seja dentro de um mesmo núcleo há paralelismo interno à nível de instrução de assembly. Em outras palavras eles são capazes de executar mais de uma instrução ao mesmo tempo dentro de um único núcleo, mas dando ao programador a ilusão de serialidade. Não confunda superescalar com múltiplas núcleos ou hyper-thread, são coisas totalmente independentes. Um exemplo disso são todos os processadores da linha Pentium 3, eles são todos superescalares, mas sem hyper-threading e com apenas um núcleo. Outro exemplo são os processadores i3, i5 e i7 que são todos multiplos núcleos, com hyper-threading e ainda superescalares. E ainda no meio de caminho temos processadores como o AMD X4 ou X6 que são superespalares, com múltiplos núcleos, mas não usam hiper-threading. 11 Um processador superescalar possui uma buffer de instruções de tamanho fixo, uma única unidade de fetch e múltiplas unidades somadoras, multiplicadoras, divisoras, de acesso à memoria e etc. O buffer é preenchido com as instruções do código até seu limite. A unidade de fetch lança as instruções as unidades de processadores se e somente se elas não possuem dependências, no mais ela também é responsável por checar quando as mesmas forem finalizadas para poder lançar novas instruções (dependentes ou não aquelas). Cada uma das unidades de processamento (somadores, multiplicadores e etc.) compartilham um buffer menor (também de tamanho fixo) com suas unidades de processamento irmãs, ou seja, há um buffer de comandos de multiplicação, outro buffer para soma e assim sucessivamente. Destes buffers as unidades de processamento vão lendo os comandos sem se importar com dependências, apenas removendo os e executando. Depois de finalizado cada comando as unidades de processamento sinalizam ao fetch seu termino. Cada unidade de processamento é independente das demais. De fato a dificuldade de se implementar um processador superescalar se resume a reparar quando instruções são independentes e quando são dependentes. Quando há dependência as instruções mais antigas devem ser executadas primeiro.. Exemplo de como um trecho de assembly em x86 seria executado: add EAX, EBX; add ECX, EBX; Pode rodar em paralelo com a primeira instrução. add EBX, EBX; Tem que esperar que a 1 e a 2 instruções terminem, pois EBX terá valor modificado. mul EDX; Precisa esperar a 1 instrução terminar pois esta instrução escreve em EAX e EDX. mov ESI, [label]; É completamente independente e pode rodar sem esperar nenhuma das anteriores. Um comando (Em x86 ou no SupVet®) recebe parâmetros que podem ser de entrada(leitura), saída (escrita) ou ambos. Uma dependência acontece nas seguintes situações: O comando tem um parâmetro de entrada que esta sendo usado como parâmetro de saída por outra unidade de processamento. O comando tem um parâmetro de saída que esta sendo usado como parâmetro de qualquer tipo em outra unidade de processamento. Nessas situações o comando espera a finalização dos comandos anteriores. Usando esses conceitos foi então elaborado um projeto de um novo superescalar, o SupVet®. Que por motivos de orçamento deve ser por enquanto apenas simulado com um único núcleo usando 12 pthreads. Alguns especialistas em hardware alegam também que o SupVet® nunca será viável de ser implementado, mas isso não afeta o fato que ele é sim simulável. O SupVet® opera somente com vetores de floats. E eles tem tamanho reajustado automaticamente em suas instruções. Na entrada da simulação terão comandos em um arquivo chamado in.txt. Na primeira linha da entrada teremos um número dizendo quantas variáveis serão inicializadas durante sua execução, se seguiram então N linhas com o comando “new” cada um destes comandos inicializando uma variável com um nome. Essas variáveis devem permanecer até o final do programa, e não serão criadas novas variáveis depois deste inicio. new TAMANHO VARIAVEL valor valor … valor Onde TAMANHO é o tamanho do vetor e VARIAVEL é o label a qual este vetor deve ser vinculado. Igualmente se seguirá TAMANHO valores de float na mesma linha um para cada casa deste novo vetor. Esta primeira parte do código, onde se inicializam variáveis, não precisa ser paralelizado e deve ser feito apenas pela thread-fetch. Depois destas N linhas os comandos possíveis são os seguintes e iram seguir um por linha até o final do arquivo: print VARIAVEL mov VARIAVEL_A mul VARIAVEL_A div VARIAVEL_A add VARIAVEL_A sub VARIAVEL_A VARIAVEL_B VARIAVEL_B VARIAVEL_B VARIAVEL_B VARIAVEL_B VARIAVEL_C VARIAVEL_C VARIAVEL_C VARIAVEL_C Os nomes das variável seguem o padrão de C. Na segunda parte do código não haverá comandos do tipo new. O comando print deve imprimir na tela o nome da variável e todas as suas casas em uma tabulação legível escolhida pelo aluno. Os operadores de soma, multiplicação, subtração e divisão são aplicados valor a valor ao longo do vetor. O padrão sempre é a primeira variável recebe a operação aplicada as outras duas. Caso um dos vetores de entrada seja menor que o outro, a parte restante deve ser operada com o valor 0 para soma e subtração e com valor 1 para divisão e multiplicação. Sendo assim a saída sempre terá tamanho igual a max ( TAMANHO(VARIAVEL_B) , TAMANHO(VARIAVEL_C) ). Caso seja preciso realoque o vetor da VARIAVEL_A para se ajustar, para mais ou para menos ao seu novo tamanho. O comando mov apenas copia os valores e o tamanho da VARIVEL_B para a VARIAVEL_A. VARIAVEL_A, VARIVEL_B e VARIAVEL_C podem ser a mesma variável. 13 Haverá 6 tipos de threads: Thread-fetch Thread-multiplicadora Thread-IO Thread-divisora Thread-subtradora Thread-somadora As unidades de IO tem com função processar os comandos de print. Nestes a unidade de IO imprime os valores na tela, mas deve ser garantido que não haverá race-condition no uso do stdout. As saídas na tela devem ser na ordem que foram chamadas no código, vocês podem interpretar que todo print tem stdout como parâmetro de saída, e que assim todos os print tem dependência entre si. Os comandos new e a desalocação no final de toda a execução devem ser interpretado pelo próprio featch, que tem como função também administrar as variáveis. Não haverão erros de sintaxe na entrada. Seu programa deve aceitar os seguintes parâmetros de précompilação(#define) com valores maiores ou iguais a 1: NUM_THREADS_MUL NUM_THREADS_DIV NUM_THREADS_SUB NUM_THREADS_SOM TAMANHO_BUFFER_FETCH // Tamanho do buffer de fetch TAMANHO_BUFFER_PROC // Tamanho dos buffers de processamento ( um por tipo de unidade de processamento ). O número de threads-fetch e threads-IO serão sempre 1 (para cada). A thread-fetch deve ser a própria thread-main do seu programa. As threads devem ser reutilizadas do começo ao fim do programa. A thread fetch deve ler o próprio buffer de comandos e seguindo a ordem dos comandos e dependências deve despachar para os buffers das unidades de processamento. Quando uma instrução se finalizar na unidade de processamento a mesma pode ser removida do buffer do fetch e só então se ler mais comandos do arquivo de entrada. É totalmente proibido o uso de espera ocupada nessa questão. Cada buffer deve ter associado a ele um mutex e uma variável de condição, quando uma unidade (fetch ou não) não tiver mais o que produzir a mesma deve dormir associada a uma variável de condição onde ela pode ser acordada com a necessidade de mais trabalho. 14 As unidades de processamento apenas removem suas instruções de suas filas e ao finalizar estes comandos vão ao buffer da unidade de fetch e os marcam como comandos finalizados. Boa modularização dos buffers, comandos, estruturas de cada vetor-variável, estruturas do que é um comando são de encargo da dupla que esta fazendo a lista e devem ser legíveis e de fácil entendimento. Igualmente alocação dinâmica bem realizada, programa livre de acesso indevido, deadlocks ou starvation também são vossa responsabilidade. Reparem que a saída para o usuário é determinística. No entanto a ordem da execução dos passos internos não o é. Exemplo: 4 new 3 a 0 0 0 new 3 b 2.0 3.0 4.0 new 3 c 1.0 2.0 3.0 new 1 aux 0 print aux add a b c add aux a print a print aux mul b b c print b Saida de exemplo ( a formatação dos valores é de escolha do aluno como já dito): aux a = aux b = = { = { { 0.0 3.0 , { 3.0 2.0 , } 5.0 , 7.0 } , 5.0 , 7.0 } 6.0 , 12.0 } 15