Programming
Rust
Swift
May 6

Swift против Rust — бенчмаркинг на Linux с (не)понятным финалом

Привет, Хабр!

Периодически посматриваю на Swift в качестве языка прикладного программирования для Linux — простой, динамичный, компилируемый, без сборщика мусора, а значит, теоретически, пригоден и для устройств. Решил сравнить его с чем-то таким же молодым и модным — например Rust. В качестве теста я взял прикладную задачу — парсинг и агрегация большого файла JSON, содержащего массив объектов. Исходники старался оформлять в едином стиле, сравнивал по 4-м параметрам: скорость исполнения, размер бинарника, размер исходника, субъективные впечатления от кодинга.

Подробнее о задаче. Имеется файл JSON размером 100 Мб, внутри массив из миллиона объектов. Каждый объект представляет собой запись о долге — название компании, список телефонов и сумма долга. Одни и те же телефоны могут использовать разные компании, и по этому признаку их нужно сгруппировать, т.е. выделить реальных дебиторов, имеющих список названий, список телефонов, и суммарный долг. Исходные объекты «грязные», т.е. данные могут быть записаны в виде строк / чисел / массивов / объектов.

Результаты бенчмаркинга меня озадачили. Подробности и исходные тексты — под катом.

Исходный JSON:
[
    {"company":"Рога и копыта", "debt": 800, "phones": [123, 234, 456]},
    {"company":"Первая коллекторская", "debt": 1200, "phones": ["2128506", 456, 789]},
    {"company":"Святой престол", "debt": "666", "phones": 666},
    {"company": "Казачий спас", "debt": 1500, "phones": [234567, "34567"], "phone": 666},
    {"company": {"name": "Шестерочка"}, "debt": 2550, "phones": 788, "phone": 789},
...

Детали реализации


Задача распадается на 4 стадии:

1) Буферизованное посимвольное чтение файла, потоковый парсинг и выделение объектов из массива. Не стал заморачиваться поиском библиотек типа YAJL, ведь мы точно знаем что внутри массив, и выделить объекты можно путем подсчета открывающих и закрывающих фигурных скобок {}, благо что они ASCII, а не многобайтные Unicode. На удивление, не обнаружил в обоих языках функции посимвольного чтения из потока Unicode — полное безобразие, слава богу что парсеры JSON берут эту работу на себя, иначе пришлось бы велосипедить с битовыми масками и сдвигами.

2) Cтроки-объекты, выделенные на стадии 1, передаем штатному парсеру JSON, на выходе получаем динамическую структуру (Any в Swift и JsonValue в Rust).

3) Копаемся в динамических данных, на выходе формируем типизированную структуру:
//source data
class DebtRec {
    var company: String
    var phones: Array<String>
    var debt: Double
}

4) Агрегируем запись о долге — ищем по номеру телефона дебитора (либо создаем), и обновляем его атрибуты. Для чего используем еще 2 структуры:
//result data
class Debtor {
    var companies: Set<String>
    var phones: Set<String>
    var debt: Double
}
class Debtors {
    var all: Array<Debtor>
    var index_by_phone: Dictionary<String, Int>
}

Итоговых дебиторов храним в динамическом массиве (векторе), для быстрого поиска по телефону используем индексную хеш-таблицу, в которой для каждого телефона храним ссылку на дебитора. Упс… Помня, что Rust не поощряет хранение реальных ссылок (даже иммутабельных), используем вместо ссылки числовой индекс дебитора в массиве all — доступ по индексу дешевая операция. Хотя, конечно, если все перейдут на доступ по индексам и хешам, получим не приложение, а какую-то СУБД. Может Rust этого от нас и добивается?

P.S.
Мой код на Rust далек от идеального — например, много to_string(), тогда как правильнее было бы заморочиться со ссылками и временами жизни (хотя предполагаю, что умный компилятор сделал это за меня). Что касается Swift — код также весьма далек от совершенства, но ведь в этом и цель бенчмаркинга — показать как простой человек склонен решать задачи на том или ином языке, и что из этого получается.

Результаты тестирования


Проекты компилировались со стандартными опциями:
swift build -c release
cargo build --release


Дебажная версия Rust показала чудовищную производительность в 86 секунд, возможно, честно отрабатывая мои to_string() (а может вообще в машинный код не переводила? <шутка>). Для Swift разница в дебажной и релизной версии оказалась незначительной. Сравниваем только релизные версии.

Скорость чтения и обработки 1 млн. объектов
Swift: 50 секунд
Rust: 4.31 секунды, то есть в 11.5 раз быстрее

Размер бинарного кода
Swift:
Сам бинарник 62 Kb, но библиотеки runtime — 9 штук на сумму 54,6 Мб (я считал только те, без которых программа действительно не запускается)
Rust:
Бинарник получился не маленким — 1,9 Мб, зато он один («lto=true» ужимает до 950 Кб, но компилирует существенно дольше).

Размер исходного кода
Swift: 189 строк, 4.5 Kb
Rust: 230 строк, 5.8 Кб

Впечатления от языка
Что касается кодинга — бесспорно, Swift гладкий и приятный глазу, особенно в сравнении с «ершистым» Rust, и программы получаются компактнее. Я не буду придираться к мелочам, отмечу лишь те грабли, на которые наступил сам при изучении. Простите, могу быть субъективен.

1) Именования объектов стандартной библиотеки Swift (а также Foundation) не так интуитивны и структурированы как в Rust, видимо по причине необходимости тащить наследие предков. Без документации порой сложно догадаться, какой метод или объект нужно искать. Перегруженные конструкторы конечно добавляют приятной магии, но данный подход, похоже, совсем не молодежный, и мне ближе принцип именования фабричных методов в Rust — from_str(), from_utf8() и т.д.

2) Обилие унаследованных объектов + перегрузка методов в Swift облегчает возможность начинающему программисту выстрелить себе в ногу. Например, в качестве промежуточного буфера прочитанных из файла байт я сначала использовал Data(), который как раз требуется на вход парсеру JSON. Этот Data имеет те же методы, что и Array, т.е. позволяет добавлять байты, да и по сути это одно и то же. Однако, производительность с Data была в несколько раз (!) ниже, чем в нынешнем варианте с Array. В Rust разница в производительности между векторами и слайсами практически не ощущается, а API доступа настолько разные, что никак не перепутать.
PS
В комментариях — специалисты по Swift смогли ускорить код в несколько раз, но это уже магия профессионалов, тогда как Rust смогли ускорить только на 14%. Получается, что порог вхождения в Rust на самом деле ниже, а не выше, как принято думать, и злой компилятор не оставляет никакой свободы «сделать что-то не так».


3) Опциональный тип данных Swift (а также оператор приведения типов) сделаны синтаксически более изящно, через постфиксы ?! — в отличие от неуклюжего растового unwrap(). Однако растовый match позволяет единообразно обрабатывать типы Option, Result, Value, получая, при необходимости, доступ к тексту ошибки. В Swift же в разных местах используется то возврат Optional, то бросок исключения, и это иногда сбивает с толку.

4) Объявления внутренних функций в Swift не всплывают, поэтому их приходится объявлять выше по тексту, что странно, ведь во всех остальных языках внутренние функции можно объявлять в конце.

5) В Rust встречаются кривые синтаксические конструкции, например если нужно проверить значение JSON на пустоту, приходится писать один из 2-х смешных бредов:
if let Null = myVal {
    ...
}
match myVal {
    Null => {
        ...
    }
    _ => {}
}

хотя напрашиваются очевидные варианты:
if myVal is Null {
    ...
}
if myVal == Option::Null {
    ...
}

Поэтому и приходится в библиотеках создавать кучу методов is_str(), is_null(), is_f64() для каждого enum-типа, что, конечно, жуткие синтаксические костыли.
PS
Судя по всему, это скоро починят, в комментариях есть ссылка на proposal.


Резюме


Так что же так тормозит в свифте? Разложим на стадии:

1) Чтение файла, потоковый парсинг с выделеним объектов
Swift: 7.46 секунд
Rust: 0.75 секунд

2) Парсинг JSON в динамический объект
Swift: 21.8 секунд
— это миллион вызовов: JSONSerialization.jsonObject(with: Data(obj))
Rust: 1.77 секунд
— это миллион вызовов: serde_json::from_slice(&obj)

3) Преобразование Any в типизированную структуру
Swift: 16.01 секунд
Rust: 0.88 секунд
— допускаю, что можно написать оптимальнее, но мой код на Rust такой же «тупой» как и на Swift

4) Агрегация
Swift: 4.74 секунд
Rust: 0.91 секунд

То есть мы видим, что в языке Swift тормозит все, и его надо сравнивать с системами типа Node.js или Python, причем я не уверен, в чью пользу будет бенчмаркинг. Принимая во внимание огромность рантайма — об использовании в устройствах вообще можно забыть. Получается, что алгоритм подсчета ссылок гораздо медленнее сборщика мусора? Тогда что, все учим Go и MicroPython?

Rust — красавчик, хотя задача была слишком простой, и погружаться в ад заимствований и лайфтаймов не было необходимости. Конечно, было бы неплохо протестировать растовые Rc<> на предмет торможения, а еще хочется прогнать данный тест на Node, Go и Java, но жаль свободного времени (хотя, по моим прикидкам, Javascript будет медленнее всего в 2.5 раза).

P.S.
Буду благодарен растаманам и свифтерам за комментарии — что не так с моим кодом.

Исходные тексты


Swift:
main.swift
import Foundation

let FILE_BUFFER_SIZE = 50000 

//source data
class DebtRec {
    var company: String = ""
    var phones: Array<String> = []
    var debt: Double = 0.0
}
//result data
class Debtor {
    var companies: Set<String> = []
    var phones: Set<String> = []
    var debt: Double = 0.0
}
class Debtors {
    var all: Array<Debtor> = []
    var index_by_phone: Dictionary<String, Int> = [:]
}


func main() {
    var res = Debtors()

    var fflag = 0
    for arg in CommandLine.arguments {
        if arg == "-f" {
            fflag = 1
        }
        else if fflag == 1 {
            fflag = 2
            print("\(arg):")
            let tbegin = Date()

            let (count, errcount) = process_file(fname: arg, res: &res)

            print("PROCESSED: \(count) objects in \(DateInterval(start: tbegin, end: Date()).duration)s, \(errcount) errors found")
        }
    }

    for (di, d) in res.all.enumerated() {
        print("-------------------------------")
        print("#\(di): debt: \(d.debt)")
        print("companies: \(d.companies)\nphones: \(d.phones)")
    }

    if fflag < 2 {
        print("USAGE: fastpivot -f \"file 1\" -f \"file 2\" ...")
    }
}


func process_file(fname: String, res: inout Debtors) -> (Int, Int) {
    var count = 0
    var errcount = 0

    if let f = FileHandle(forReadingAtPath: fname) {
        var obj: Array<UInt8> = []
        var braces = 0

        while true {
            let buf = f.readData(ofLength: FILE_BUFFER_SIZE)
            if buf.isEmpty {
                break //EOF
            }
            for b in buf {
                if b == 123 { // {
                    braces += 1
                    obj.append(b)
                }
                else if b == 125 { // }
                    braces -= 1
                    obj.append(b)

                    if braces == 0 { //object formed !

                        do {
                            let o = try JSONSerialization.jsonObject(with: Data(obj))
                            process_object(o: (o as! Dictionary<String, Any>), res: &res)
                        } catch {
                            print("JSON ERROR: \(obj)")
                            errcount += 1
                        }

                        count += 1
                        obj = []
                    }
                }
                else if braces > 0 {
                    obj.append(b)
                }
            }
        }
    } else {
        print("ERROR: Unable to open file")
    }
    return (count, errcount)
}


func process_object(o: Dictionary<String, Any>, res: inout Debtors) {
    let dr = extract_data(o)
    //print("\(dr.company) - \(dr.phones) - \(dr.debt)")

    var di: Optional<Int> = Optional.none //debtor index search result
    for p in dr.phones {
        if let i = res.index_by_phone[p] {
            di = Optional.some(i)
            break
        }
    }
    if let i = di { //existing debtor
        let d = res.all[i]
        d.companies.insert(dr.company)
        for p in dr.phones {
            d.phones.insert(p)
            res.index_by_phone[p] = i
        }
        d.debt += dr.debt
    }
    else { //new debtor
        let d = Debtor()
        let i = res.all.count

        d.companies.insert(dr.company)
        for p in dr.phones {
            d.phones.insert(p)
            res.index_by_phone[p] = i
        }
        d.debt = dr.debt

        res.all.append(d)
    }
}


func extract_data(_ o: Dictionary<String, Any>) -> DebtRec {

    func val2str(_ v: Any) -> String {
        if let vs = (v as? String) {
            return vs
        }
        else if let vi = (v as? Int) {
            return String(vi)
        }
        else {
            return "null"
        }
    }

    let dr = DebtRec()

    let c = o["company"]!
    if let company = (c as? Dictionary<String, Any>) {
        dr.company = val2str(company["name"]!)
    } else {
        dr.company = val2str(c)
    }

    let pp = o["phones"]
    if let pp = (pp as? Array<Any>) {
        for p in pp {
            dr.phones.append(val2str(p))
        }
    } 
    else if pp != nil {
        dr.phones.append(val2str(pp!))
    }       

    let p = o["phone"]
    if p != nil {
        dr.phones.append(val2str(p!))
    }        

    if let d = o["debt"] {
        if let dd = (d as? Double) {
            dr.debt = dd
        }
        else if let ds = (d as? String) {
            dr.debt = Double(ds)!
        }
    }

    return dr
}

main()


Rust:
main.rs
//[dependencies]
//serde_json = "1.0"

use std::collections::{HashMap, HashSet};
use serde_json::Value;

const FILE_BUFFER_SIZE: usize = 50000;

//source data
struct DebtRec {
    company: String,
    phones: Vec<String>,
    debt: f64
}
//result data
struct Debtor {
    companies: HashSet<String>,
    phones: HashSet<String>,
    debt: f64
}
struct Debtors {
    all: Vec<Debtor>,
    index_by_phone: HashMap<String, usize>
}


impl DebtRec {
    fn new() -> DebtRec {
        DebtRec {
            company: String::new(),
            phones: Vec::new(),
            debt: 0.0
        }
    }
}
impl Debtor {
    fn new() -> Debtor {
        Debtor {
            companies: HashSet::new(),
            phones: HashSet::new(),
            debt: 0.0
        }
    }
}
impl Debtors {
    fn new() -> Debtors {
        Debtors {
            all: Vec::new(),
            index_by_phone: HashMap::new()
        }
    }
}


fn main() {
    let mut res = Debtors::new();

    let mut fflag = 0;
    for arg in std::env::args() {
        if arg == "-f" {
            fflag = 1;
        }
        else if fflag == 1 {
            fflag = 2;
            println!("{}:", &arg);
            let tbegin = std::time::SystemTime::now();

            let (count, errcount) = process_file(&arg, &mut res);

            println!("PROCESSED: {} objects in {:?}, {} errors found", count, tbegin.elapsed().unwrap(), errcount);
        }
    }

    for (di, d) in res.all.iter().enumerate() {
        println!("-------------------------------");
        println!("#{}: debt: {}", di, &d.debt);
        println!("companies: {:?}\nphones: {:?}", &d.companies, &d.phones);
    }

    if fflag < 2 {
        println!("USAGE: fastpivot -f \"file 1\" -f \"file 2\" ...");
    }
}


fn process_file(fname: &str, res: &mut Debtors) -> (i32, i32) { 
    use std::io::prelude::*;

    let mut count = 0;
    let mut errcount = 0;

    match std::fs::File::open(fname) {
        Ok(file) => {
            let mut freader = std::io::BufReader::with_capacity(FILE_BUFFER_SIZE, file);
            let mut obj = Vec::new();
            let mut braces = 0;

            loop {
                let buf = freader.fill_buf().unwrap();
                let blen = buf.len();
                if blen == 0 {
                    break; //EOF
                }
                for b in buf {
                    if *b == b'{' {
                        braces += 1;
                        obj.push(*b);
                    }
                    else if *b == b'}' {
                        braces -= 1;
                        obj.push(*b);

                        if braces == 0 { //object formed !

                            match serde_json::from_slice(&obj) {
                                Ok(o) => {
                                    process_object(&o, res);
                                }
                                Err(e) => {
                                    println!("JSON ERROR: {}:\n{:?}", e, &obj);
                                    errcount +=1;
                                }
                            }

                            count += 1;
                            obj = Vec::new();
                        }
                    }
                    else if braces > 0 {
                        obj.push(*b);
                    }
                }
                freader.consume(blen);
            }
        }
        Err(e) => {
            println!("ERROR: {}", e);
        }
    }
    return (count, errcount);
}


fn process_object(o: &Value, res: &mut Debtors) {
    let dr = extract_data(o);
    //println!("{} - {:?} - {}", &dr.company, &dr.phones, &dr.debt,);

    let mut di: Option<usize> = Option::None; //debtor index search result
    for p in &dr.phones {
        if let Some(i) = res.index_by_phone.get(p) {
            di = Some(*i);
            break;
        }
    }
    match di {
        Some(i) => { //existing debtor
            let d = &mut res.all[i];
            d.companies.insert(dr.company);
            for p in &dr.phones {
                d.phones.insert(p.to_string());
                res.index_by_phone.insert(p.to_string(), i);
            }
            d.debt += dr.debt;
        }
        None => { //new debtor
            let mut d = Debtor::new();
            let i = res.all.len();

            d.companies.insert(dr.company);
            for p in &dr.phones {
                d.phones.insert(p.to_string());
                res.index_by_phone.insert(p.to_string(), i);
            }
            d.debt = dr.debt;

            res.all.push(d);
        }
    }
}


fn extract_data(o: &Value) -> DebtRec {
    use std::str::FromStr;

    let mut dr = DebtRec::new();

    let c = &o["company"];
    dr.company =
        match c {
            Value::Object(c1) =>
                match &c1["name"] {
                    Value::String(c2) => c2.to_string(),
                    _ => val2str(c)
                },
            _ => val2str(c)
        };

    let pp =  &o["phones"];
    match pp {
        Value::Null => {}
        Value::Array(pp) => {
            for p in pp {
                dr.phones.push(val2str(&p));
            }
        }
        _ => {dr.phones.push(val2str(&pp))}
    }

    let p = &o["phone"];
    match p {
        Value::Null => {}
        _ => {dr.phones.push(val2str(&p))}
    }

    dr.debt =
        match &o["debt"] {
            Value::Number(d) => d.as_f64().unwrap_or(0.0),
            Value::String(d) => f64::from_str(&d).unwrap_or(0.0),
            _ => 0.0
        };

    return dr;

    fn val2str(v: &Value) -> String {
        match v {
            Value::String(vs) => vs.to_string(), //to avoid additional quotes
            _ => v.to_string()
        }
    }
}


Тестовый файл.
+20
9.8k 36
Comments 438
Top of the day