Базовые трейты объектов. Ч2
#новичкам
В прошлый раз у нас была довольно простая С-like структура, для которой мы смотрели стандартные свойства.
Сегодня посмотрим, какие свойства типа изменятся, если совсем чуть-чуть изменить структуру.
🔀 Добавим просто std::string в качестве поля структуры.
Из-за того, что у std::string нетривиальные специальное методы классов, то это автоматически делает нетривиальными специальные методы у
🔀 Добавим виртуальный метод
Очевидно, тип сразу же стал полиморфным. Но и много чего потерял. Чисто по определению некоторых свойств полиморфный тип не может им удовлетворять. Это:
👉🏿 std::is_standard_layout - грубо говоря, тип становится невместим с С
👉🏿 std::is_trivially_copyable - тривиальное копирование подразумевает простое копирование по байтам. Копирование полиморфного типа нельзя производить побайтово. Компилятор обязан либо скопировать vptr как есть (если это тот же тип), либо установить правильный vptr нового типа (если копируем через базовый класс — slicing). Это уже нетривиальная логика.
👉🏿 std::is_trivially_default_constructible - конструктор по умолчанию не тривиальный, так как надо инициализировать vptr в правильную vtable.
👉🏿 std::is_aggregate - как только у класса появляется приватный метод(vptr в данном случае), он уже не может быть агрегатом.
🔀 Более менее понятно, что будет, если добавить пользовательский конструктор. А что если добавить кастомный деструктор?
Он никак не влияет на layout объекта и на способность объекта быть созданным агрегацией. Однако объект перестает быть тривиально копируемым и создаваемым копией или перемещением. С перемещением понятно - оно не генерится с кастомным деструктором.
А с копированием интересно. Стандарт говорит, что при установке требований на создание объекта нужно учитывать и разрушение объекта. Поэтому объект типа А нельзя создать тривиально копией(хотя конструктор копирования продолжает генериться компилятором и быть тривиальным).
Для is_trivially_copyable нужен дефолтный деструктор, чтобы не было никакой дополнительной логики освобождения ресурсов при побайтовом копировании одного объекта в другой.
Это все звучит, как ненужные и неважные детали. Но мы здесь грокаем С++. И просто интересно, почему, с точки зрения архитектуры языка, ломается тривиальность дефолтного конструктора при добавлении виртуальной функции.
Know your traits. Stay cool.
#template #cppcore
#новичкам
В прошлый раз у нас была довольно простая С-like структура, для которой мы смотрели стандартные свойства.
struct A {
int a;
int b;
int c;
};Сегодня посмотрим, какие свойства типа изменятся, если совсем чуть-чуть изменить структуру.
🔀 Добавим просто std::string в качестве поля структуры.
struct A {
int a;
int b;
int c;
std::string s;
};Из-за того, что у std::string нетривиальные специальное методы классов, то это автоматически делает нетривиальными специальные методы у
A, поэтому отличие будет только в следующих трейтах:static_assert(!std::is_trivially_copyable_v<A>);
static_assert(!std::is_trivially_destructible_v<A>);
static_assert(!std::is_trivially_copy_constructible_v<A>);
static_assert(!std::is_trivially_move_constructible_v<A>);
static_assert(!std::is_trivially_copy_assignable_v<A>);
static_assert(!std::is_trivially_move_assignable_v<A>);
static_assert(!std::is_trivially_default_constructible_v<A>);
🔀 Добавим виртуальный метод
struct A {
int a;
int b;
int c;
virtual void foo() {}
};Очевидно, тип сразу же стал полиморфным. Но и много чего потерял. Чисто по определению некоторых свойств полиморфный тип не может им удовлетворять. Это:
👉🏿 std::is_standard_layout - грубо говоря, тип становится невместим с С
👉🏿 std::is_trivially_copyable - тривиальное копирование подразумевает простое копирование по байтам. Копирование полиморфного типа нельзя производить побайтово. Компилятор обязан либо скопировать vptr как есть (если это тот же тип), либо установить правильный vptr нового типа (если копируем через базовый класс — slicing). Это уже нетривиальная логика.
👉🏿 std::is_trivially_default_constructible - конструктор по умолчанию не тривиальный, так как надо инициализировать vptr в правильную vtable.
👉🏿 std::is_aggregate - как только у класса появляется приватный метод(vptr в данном случае), он уже не может быть агрегатом.
static_assert(std::is_polymorphic_v<Poly>);
static_assert(!std::is_standard_layout_v<Poly>);
static_assert(!std::is_trivially_copyable_v<Poly>);
static_assert(!std::is_trivially_default_constructible_v<Poly>);
static_assert(!std::is_aggregate_v<Poly>);
🔀 Более менее понятно, что будет, если добавить пользовательский конструктор. А что если добавить кастомный деструктор?
struct A {
int a;
int b;
int c;
~A() {}
};
static_assert(!std::is_trivially_destructible_v<A>);
static_assert(!std::is_trivially_copyable_v<A>);
static_assert(!std::is_trivially_copy_constructible_v<A>);
static_assert(!std::is_trivially_move_constructible_v<A>);
static_assert(std::is_aggregate_v<A>);
static_assert(std::is_standard_layout_v<A>);Он никак не влияет на layout объекта и на способность объекта быть созданным агрегацией. Однако объект перестает быть тривиально копируемым и создаваемым копией или перемещением. С перемещением понятно - оно не генерится с кастомным деструктором.
А с копированием интересно. Стандарт говорит, что при установке требований на создание объекта нужно учитывать и разрушение объекта. Поэтому объект типа А нельзя создать тривиально копией(хотя конструктор копирования продолжает генериться компилятором и быть тривиальным).
Для is_trivially_copyable нужен дефолтный деструктор, чтобы не было никакой дополнительной логики освобождения ресурсов при побайтовом копировании одного объекта в другой.
Это все звучит, как ненужные и неважные детали. Но мы здесь грокаем С++. И просто интересно, почему, с точки зрения архитектуры языка, ломается тривиальность дефолтного конструктора при добавлении виртуальной функции.
Know your traits. Stay cool.
#template #cppcore
👍12❤8🔥4
Безопасный memcpy
#новичкам
memcpy — это один из самых быстрых способов скопировать данные и один из самых быстрых способов получить UB. Функция принимает
В прошлых постах мы поговорили о трейтах. Для заданного типа они определяют, обладает ли тип определенным свойством или нет.
Давайте применим трейты на практике. Ограничим через них доступ к самописной обертке memcpy.
И в этом нам помогут концепты. Если трейты - это свойство типа, то концепт - это требование для типа.
Определим концепт, который требует, чтобы тип был объектом(не функцией и не ссылкой) и был тривиально копируемым:
И вот теперь мы говорим в шаблонном функции, что хотим потребовать от типа выполнения условия SafeForMemcpy. Это можно сделать разными способами, вот самый простой:
Вместо ключевых слов
Теперь при использовании нетривиально копируемых типов будет появляться ошибка компиляции с нормальным сообщением о том, что тип не удовлетворяет концепту.
В будущих постах будет разбирать концепты чуть подробнее.
Require the best conditions. Stay cool.
#cpp20 #template
#новичкам
memcpy — это один из самых быстрых способов скопировать данные и один из самых быстрых способов получить UB. Функция принимает
void* и size_t и радостно съест всё, что вы ей дадите: полиморфный тип, std::string, объект с нетривиальным деструктором. Компилятор не скажет ни слова, а программа сломается в самом неожиданном месте.В прошлых постах мы поговорили о трейтах. Для заданного типа они определяют, обладает ли тип определенным свойством или нет.
Давайте применим трейты на практике. Ограничим через них доступ к самописной обертке memcpy.
И в этом нам помогут концепты. Если трейты - это свойство типа, то концепт - это требование для типа.
Определим концепт, который требует, чтобы тип был объектом(не функцией и не ссылкой) и был тривиально копируемым:
template <typename T>
concept SafeForMemcpy = std::is_object_v<T> && std::is_trivially_copyable_v<T>;
И вот теперь мы говорим в шаблонном функции, что хотим потребовать от типа выполнения условия SafeForMemcpy. Это можно сделать разными способами, вот самый простой:
template <SafeForMemcpy T>
void safe_memcpy(T* dest, const T* src) {
static_assert(sizeof(T) > 0, "Cannot copy incomplete type");
std::memcpy(dest, src, sizeof(T));
}
Вместо ключевых слов
typename или class используется имя концепта. Теперь при использовании нетривиально копируемых типов будет появляться ошибка компиляции с нормальным сообщением о том, что тип не удовлетворяет концепту.
В будущих постах будет разбирать концепты чуть подробнее.
Require the best conditions. Stay cool.
#cpp20 #template
❤26👍12🔥7
std::find vs std::ranges::find
#опытным
С++20 библиотека диапазонов принесла нам много новых полезных алгоритмов и способов управления данными. Но не только это. Рэнджи задублировали и привычные нам "древние" стандартные алгоритмы.
Хотя кажется "что какая разница, использовать новое апи или старое?" - разница все-таки есть. И сегодня поговорим об одном конкретном кейсе.
Что делает std::find? Ищет элемент в промежутке между итератором начала и конца последовательности и возвращает на него итератор на этот элемент:
Если элемент не найден, то find просто возвращает итератор на конец диапазона.
Ровно такую же строчку можно написать с std::ranges::find и это заработает:
Однако здесь открываются новые возможности. Все потому, что второй аргумент алгоритм - конец диапазона - не обязан быть того же типа, что и итератор начала:
По шаблонным параметрам видно, что во втором случае first и last могут быть разных типов, в отличие от std::find.
Это позволяет использовать кастомный тип ограничителя последовательности, который позволит сделать поиск несколько эффективнее.
std::find реализован примерно вот так:
На каждой итерации цикла идет проверка, не дошли ли мы до конца. Это нужно, чтобы мы не вышли за границы последовательности при поиске.
Но что, если у нас данные в таком формате, что мы точно знаем о существовании там нужного элемента?
Пусть мы парсим какие-то данные в заданном формате. Например, csv. Мы четко знаем, что между элементами всегда есть разделитель. Это либо запятая, либо символ конца строки.
При чтении каждого отдельного элемента мы точно знаем, что он закончится либо запятой, либо символом конца строки. В этом случае при поиске разделителя мы точно не выйдем за границы диапазона, ведь мы четко знаем, что он там будет.
В этом случае было бы довольно полезным просто выкинуть проверку на окончание диапазона. Это бы дано очень несущественный, но все же импакт в скорость программы, особенно для больших файлов.
И ограничитель std::unreachable_sentinel фактически позволяет это сделать. Причина в том, что операция проверки на равенство для него всегда возвращает false.
И хоть алгоритм std::ranges::find не сильно отличается по реализации от std::find (там тоже есть проверка конца последовательности), компилятор видит, что проверка конца всегда возвращает false и выкидывает ее из кода.
Таким образом мы экономим одну операцию при проверке очередного элемента последовательности.
Однако здесь есть очевидная проблема. Если нужный элемент по какой-то причине не будет найден в пределах заданного диапазона, то мы получаем выход за границы и выстрел UB в упор. Поэтому использовать std::unreachable_sentinel нужно очень осторожно и только в случае полной уверенности в формате данных.
Be sure. Stay cool.
#cpp20 #optimization
#опытным
С++20 библиотека диапазонов принесла нам много новых полезных алгоритмов и способов управления данными. Но не только это. Рэнджи задублировали и привычные нам "древние" стандартные алгоритмы.
Хотя кажется "что какая разница, использовать новое апи или старое?" - разница все-таки есть. И сегодня поговорим об одном конкретном кейсе.
Что делает std::find? Ищет элемент в промежутке между итератором начала и конца последовательности и возвращает на него итератор на этот элемент:
auto it = std::find(vec.begin(), vec.end(), 42);
Если элемент не найден, то find просто возвращает итератор на конец диапазона.
Ровно такую же строчку можно написать с std::ranges::find и это заработает:
auto it = std::ranges::find(vec.begin(), vec.end(), 42);
Однако здесь открываются новые возможности. Все потому, что второй аргумент алгоритм - конец диапазона - не обязан быть того же типа, что и итератор начала:
// std::find
template< class InputIt, class T >
InputIt find( InputIt first, InputIt last, const T& value );
// std::ranges::find
template< std::input_iterator I, std::sentinel_for<I> S,
class T, class Proj = std::identity >
requires std::indirect_binary_predicate
<ranges::equal_to, std::projected<I, Proj>, const T*>
constexpr I find( I first, S last, const T& value, Proj proj = {} );
По шаблонным параметрам видно, что во втором случае first и last могут быть разных типов, в отличие от std::find.
Это позволяет использовать кастомный тип ограничителя последовательности, который позволит сделать поиск несколько эффективнее.
std::find реализован примерно вот так:
template<class InputIt, class T = typename std::iterator_traits<InputIt>::value_type>
constexpr InputIt find(InputIt first, InputIt last, const T& value)
{
for (; first != last; ++first)
if (*first == value)
return first;
return last;
}
На каждой итерации цикла идет проверка, не дошли ли мы до конца. Это нужно, чтобы мы не вышли за границы последовательности при поиске.
Но что, если у нас данные в таком формате, что мы точно знаем о существовании там нужного элемента?
Пусть мы парсим какие-то данные в заданном формате. Например, csv. Мы четко знаем, что между элементами всегда есть разделитель. Это либо запятая, либо символ конца строки.
При чтении каждого отдельного элемента мы точно знаем, что он закончится либо запятой, либо символом конца строки. В этом случае при поиске разделителя мы точно не выйдем за границы диапазона, ведь мы четко знаем, что он там будет.
В этом случае было бы довольно полезным просто выкинуть проверку на окончание диапазона. Это бы дано очень несущественный, но все же импакт в скорость программы, особенно для больших файлов.
И ограничитель std::unreachable_sentinel фактически позволяет это сделать. Причина в том, что операция проверки на равенство для него всегда возвращает false.
template<std::weakly_incrementable I>
friend constexpr bool operator==( unreachable_sentinel_t, const I& ) noexcept
{ return false; }
И хоть алгоритм std::ranges::find не сильно отличается по реализации от std::find (там тоже есть проверка конца последовательности), компилятор видит, что проверка конца всегда возвращает false и выкидывает ее из кода.
Таким образом мы экономим одну операцию при проверке очередного элемента последовательности.
Однако здесь есть очевидная проблема. Если нужный элемент по какой-то причине не будет найден в пределах заданного диапазона, то мы получаем выход за границы и выстрел UB в упор. Поэтому использовать std::unreachable_sentinel нужно очень осторожно и только в случае полной уверенности в формате данных.
Be sure. Stay cool.
#cpp20 #optimization
❤13👍7🔥5
Виртуальный шаблонный метод
#опытным
Во влажных мечтах С++ программистов всегда находится место для возможностей одновременного использования статического и динамического полиморфизмов. И тут прям напрашивается иметь виртуальный шаблонный метод. Давайте напишем:
Выглядит привычно, пробуем скомпилировать.... И оно не билдится.
С++ не разрешает вам иметь виртуальный шаблонный метод.
Но почему?
Надо понимать, как под капотом работают виртуальные и шаблонные функции.
При определении шаблона, никакой низкоуровневый код не генерируется. Код генерируется только, когда компилятор видит использование шаблона с каким-то конкретным типом. Подробнее можно тут почитать.
Так вот реальный код инстанциаций шаблона может быть раскидан по очень большому множеству единиц трансляции.
Теперь про виртуальные функции. В абсолютном большинстве компиляторов они реализованы через vtable. Подробнее тут. Но вкратце: во время компиляции класса с виртуальным формируется статический массив, состоящий из адресов скомпилированных виртуальных методов. Порядок нахождения в массиве определяется порядком объявления методов в классе.
Почему вообще возможна такая генерация? Потому что компилятор уже в этой конкретной единице трансляции знает весь набор виртуальных методов в базовом классе и в наследнике. Поэтому он заранее может выбрать правильный размер vtable.
Теперь пытаемся совместить. Для генерации vtable на этапе компиляции нам нужно знать весь набор виртульных методов. И это прямо противоречит тому факту, что шаблон ничего не знает о своих потенциальных инстанциациях, которые разбросаны по куче единиц трансляции. Каждая единица трансляции компилируется независимо, поэтому получить информацию об адресах конкретных инстанциаций из других TU невозможно.
Хорошо, что в С++ есть множество других инструментов и концепций, которые помогут решить вашу техническую проблему в без виртуальных шаблонных методов. Это type erasure, crtp, visitor, std::function и std::any.
Combine the incompatible. Stay cool.
#cppcore #interview
#опытным
Во влажных мечтах С++ программистов всегда находится место для возможностей одновременного использования статического и динамического полиморфизмов. И тут прям напрашивается иметь виртуальный шаблонный метод. Давайте напишем:
struct Base
{
template <typename T>
virtual void do_something(T arg) = 0;
virtual ~Base() = default;
};
struct Derived : Base
{
template <typename T>
void do_something(T arg) override
{
// do something
}
};
Выглядит привычно, пробуем скомпилировать.... И оно не билдится.
С++ не разрешает вам иметь виртуальный шаблонный метод.
Но почему?
Надо понимать, как под капотом работают виртуальные и шаблонные функции.
При определении шаблона, никакой низкоуровневый код не генерируется. Код генерируется только, когда компилятор видит использование шаблона с каким-то конкретным типом. Подробнее можно тут почитать.
Так вот реальный код инстанциаций шаблона может быть раскидан по очень большому множеству единиц трансляции.
Теперь про виртуальные функции. В абсолютном большинстве компиляторов они реализованы через vtable. Подробнее тут. Но вкратце: во время компиляции класса с виртуальным формируется статический массив, состоящий из адресов скомпилированных виртуальных методов. Порядок нахождения в массиве определяется порядком объявления методов в классе.
Почему вообще возможна такая генерация? Потому что компилятор уже в этой конкретной единице трансляции знает весь набор виртуальных методов в базовом классе и в наследнике. Поэтому он заранее может выбрать правильный размер vtable.
Теперь пытаемся совместить. Для генерации vtable на этапе компиляции нам нужно знать весь набор виртульных методов. И это прямо противоречит тому факту, что шаблон ничего не знает о своих потенциальных инстанциациях, которые разбросаны по куче единиц трансляции. Каждая единица трансляции компилируется независимо, поэтому получить информацию об адресах конкретных инстанциаций из других TU невозможно.
Хорошо, что в С++ есть множество других инструментов и концепций, которые помогут решить вашу техническую проблему в без виртуальных шаблонных методов. Это type erasure, crtp, visitor, std::function и std::any.
Combine the incompatible. Stay cool.
#cppcore #interview
❤17👍5🔥4
std::source_location
#новичкам
Если вы до сих пор не знали, как платформонезависимо(безо всяких PRETTY_FUNCTION) получать человеческий доступ к имени файла, имени функции и строчке кода, где вы сейчас находитесь, то пост для вас.
В С++20 появился класс std::source_location, который инкапсулирует в себе информацию о точке исходного кода, где был создан соответствующий объект.
Применение до боли простое. У класса есть набор методов, которые в сумме и дают понимание о том, где был создан объект. Вы просто создаете объект через единственный статический метод std::source_location::current и получаете, что нужно. Пример:
В момент вызова функции log компилятор должен вычислить все параметры, переданные в функцию. Поэтому параметр location с дефолтным параметром std::source_location::current() как раз и делает нужный трюк - location неявно присваивается расположение вызова функции в коде всякий раз, когда кто-то ее дергает.
После чего нужно сериализовать location через вызовы методов в нужном порядке и вуаля, красивый лог:
В больших фреймворках, типа userver вам конечно же не нужно о таком беспокоиться. Но если вы пилите что-то свое кастомное, то std::source_location - хорошая тула для дебаггинга и логирования.
Define your place. Stay cool.
#cpp20
#новичкам
Если вы до сих пор не знали, как платформонезависимо(безо всяких PRETTY_FUNCTION) получать человеческий доступ к имени файла, имени функции и строчке кода, где вы сейчас находитесь, то пост для вас.
В С++20 появился класс std::source_location, который инкапсулирует в себе информацию о точке исходного кода, где был создан соответствующий объект.
Применение до боли простое. У класса есть набор методов, которые в сумме и дают понимание о том, где был создан объект. Вы просто создаете объект через единственный статический метод std::source_location::current и получаете, что нужно. Пример:
#include <iostream>
#include <source_location>
#include <string_view>
void log(const std::string_view message,
const std::source_location location =
std::source_location::current()) {
std::clog << "file: "
<< location.file_name() << '('
<< location.line() << ':'
<< location.column() << ") `"
<< location.function_name() << "`: "
<< message << '\n';
}
int main() {
log("Hello world!");
}
В момент вызова функции log компилятор должен вычислить все параметры, переданные в функцию. Поэтому параметр location с дефолтным параметром std::source_location::current() как раз и делает нужный трюк - location неявно присваивается расположение вызова функции в коде всякий раз, когда кто-то ее дергает.
После чего нужно сериализовать location через вызовы методов в нужном порядке и вуаля, красивый лог:
file: /app/example.cpp(17:8) int main(): Hello world!
В больших фреймворках, типа userver вам конечно же не нужно о таком беспокоиться. Но если вы пилите что-то свое кастомное, то std::source_location - хорошая тула для дебаггинга и логирования.
Define your place. Stay cool.
#cpp20
❤20👍11🔥5🐳1
Агенты добрались до продакшена
Про AI-агентов сейчас не говорит только ленивый. Обычно это выглядит так: «мы прикрутили LLM, дали ей пару тулов, теперь она сама все делает».
А потом это пытаются выкатить в прод, и внезапно оказывается, что агент — это не чатик с промптом, а распределенная система с дорогущими GPU, долгими запросами, динамическим графом исполнения, ретраями, которые нельзя просто так делать, и метриками качества, которые надо в принципе уметь измерять и делать это каждый день.
На недавнем deep tech night прошел показательный доклад «От поиска к агентам: путь Алисы», который я глянул.
Круто, когда говорят не про то, как ллм и агенты делают космолеты, а какие вызовы несет в себе их использование и как сделать работу с языковыми моделями надежной, отслеживаемой и универсальной в рамках большой компании.
Изыскания ребят привели их к AGTS - агентской транспортной системе.
Если коротко, это сервис-медиатор между агентами, тулами и моделями. Все общаются не напрямую друг с другом, а через AGTS.
И вот тут начинается нормальный backend:
🔸 агенты и тулы могут быть написаны на разных языках — Python, C++, Java/Kotlin, Go;
🔸 можно переиспользовать одну инфраструктуру для прода и приемки качества;
🔸 есть «железные пользователи» — агенты, которые имитируют пользовательское поведение;
🔸 LLM-as-a-judge - модели оценки качества "услуг агентов" - тоже живут как агенты на той же платформе;
🔸 AGTS кэширует запросы и ответы к моделям и тулам, чтобы при сбое не перезапускать всю дорогую LLM-мясорубку с нуля.
Последний пункт особенно важен.
Агент упал не в самом начале, а где-нибудь на пятой минуте размышлений? Не надо снова жечь GPU и молиться. Система делает fast-forward по закэшированным данным до точки падения и продолжает оттуда.
Пожалуй один из главных поинтов доклада: harness > model.
То есть качество можно серьезно растить не только заменой модели на новую большую и дорогую, а обвязкой вокруг нее. И эти обвязки централизовано добавляются и управляются в AGTS.
Вот некоторые достижения харнесса:
🔥 +9 п.п. на BrowseComp за счет фильтрации поисковой выдачи перед подачей в модель;
🔥 +8,5 п.п. на GAIA после добавления работы с файлами, мультимодальности и OCR;
🔥 стоимость запроса снизили в 6,6 раза;
🔥 latency p90 ужали с 30 минут до 2,5 минут;
🔥 GPU на проде стало нужно в 5 раз меньше.
В общем, очень ценный доклад именно с точки зрения раскрытия внутрянки такой сложной системы. Без AI мы уже никуда, но включение человека всё еще необходимо, чтобы заставить его работать с профитом и предсказуемостью на больших масштабах.
Про AI-агентов сейчас не говорит только ленивый. Обычно это выглядит так: «мы прикрутили LLM, дали ей пару тулов, теперь она сама все делает».
А потом это пытаются выкатить в прод, и внезапно оказывается, что агент — это не чатик с промптом, а распределенная система с дорогущими GPU, долгими запросами, динамическим графом исполнения, ретраями, которые нельзя просто так делать, и метриками качества, которые надо в принципе уметь измерять и делать это каждый день.
На недавнем deep tech night прошел показательный доклад «От поиска к агентам: путь Алисы», который я глянул.
Круто, когда говорят не про то, как ллм и агенты делают космолеты, а какие вызовы несет в себе их использование и как сделать работу с языковыми моделями надежной, отслеживаемой и универсальной в рамках большой компании.
Изыскания ребят привели их к AGTS - агентской транспортной системе.
Если коротко, это сервис-медиатор между агентами, тулами и моделями. Все общаются не напрямую друг с другом, а через AGTS.
И вот тут начинается нормальный backend:
🔸 агенты и тулы могут быть написаны на разных языках — Python, C++, Java/Kotlin, Go;
🔸 можно переиспользовать одну инфраструктуру для прода и приемки качества;
🔸 есть «железные пользователи» — агенты, которые имитируют пользовательское поведение;
🔸 LLM-as-a-judge - модели оценки качества "услуг агентов" - тоже живут как агенты на той же платформе;
🔸 AGTS кэширует запросы и ответы к моделям и тулам, чтобы при сбое не перезапускать всю дорогую LLM-мясорубку с нуля.
Последний пункт особенно важен.
Агент упал не в самом начале, а где-нибудь на пятой минуте размышлений? Не надо снова жечь GPU и молиться. Система делает fast-forward по закэшированным данным до точки падения и продолжает оттуда.
Пожалуй один из главных поинтов доклада: harness > model.
То есть качество можно серьезно растить не только заменой модели на новую большую и дорогую, а обвязкой вокруг нее. И эти обвязки централизовано добавляются и управляются в AGTS.
Вот некоторые достижения харнесса:
🔥 +9 п.п. на BrowseComp за счет фильтрации поисковой выдачи перед подачей в модель;
🔥 +8,5 п.п. на GAIA после добавления работы с файлами, мультимодальности и OCR;
🔥 стоимость запроса снизили в 6,6 раза;
🔥 latency p90 ужали с 30 минут до 2,5 минут;
🔥 GPU на проде стало нужно в 5 раз меньше.
В общем, очень ценный доклад именно с точки зрения раскрытия внутрянки такой сложной системы. Без AI мы уже никуда, но включение человека всё еще необходимо, чтобы заставить его работать с профитом и предсказуемостью на больших масштабах.
🔥7❤4👍3
Identity объекта
#опытным
На пространстве интернетов мне попался шортс, где довольно опытный в С++ чувак рассказывает про некий "трюк" в контексте такой задачи:
трюк в том, что если вам нужен идентификатор объекта, то вы можете использовать его уникальный адрес. Приводился такой пример:
Выглядит с первого взгляда круто. Действительно, у каждого объекта свой уникальный адрес. Два объекта в программе не могут занимать одну ячейку памяти. Получается, что адрес можно использовать как id и это "просто работает".
Только вот сходу можно назвать кучу проблем, к которому ведет этот подход:
🔞 id объекта меняется, если он перемещается
🔞 id объекта меняется, если он копируется(должны ли такие объекты копироваться и перемещать вообще - это вопрос, но проблемы остаются)
🔞 такой айди тесно связан с временем жизни объекта и не существует о отрыве объекта в программе
🔞 id переиспользуется другими объектами и на стеке, и на куче. Как только разрушился объект, новый объект может получить тот же самый id. И как различить объекты в таком случае непонятно.
🔞 id нельзя безопасно использовать как внешний идентификатор, аля передавать в другие сервисы или сохранять в хранилища.
Я понимаю, что скорее всего на интервью, где-то в отдельных модулях приложения или в каких-то игрушечных проектах такой подход может сработать. Но в каких-то больших распределенных системах вряд ли. Хотя мир большой и вы в комментариях расскажете, где такой подход применяется на практике.
Если хочется что-то простое и на коленке сделанное, то можно использовать приватный статический(потенциально атомарный) счетчик внутри класса и при создании объекта инкрементировать его:
При копировании, мувах Id не меняется(но это можно исправить, если надо), а чтобы новый объект заимел тот же айди нужно 100500 лет.
Однако при рестарте приложения, счетчик все еще обнуляется и будут дубли. Если вам критично, чтобы не существовало двух объектов с одинаковым id, можно использовать UUIDv7. Там 2¹²² вариантов айдишников, вероятностью совпадения при использовании uuid обычно пренебрегают.
Have your own identity. Stay cool.
#design
#опытным
На пространстве интернетов мне попался шортс, где довольно опытный в С++ чувак рассказывает про некий "трюк" в контексте такой задачи:
По сути, нужно построить центральный сервис, который отправляет обновления цен на станции, но новые желаемые цены могут поступать, пока старые обновления ещё в пути. Вас волнует только то, чтобы каждая станция в конечном итоге получила самую последнюю желаемую цену, при этом подтверждения (acknowledgements) должны приходить по порядку, а лишние обновления следует избегать. Таким образом, обновления, которые вы отправляете, могут прибывать на станцию не по порядку, но подтверждения, которые вы получаете обратно, всегда должны быть правильно упорядочены в том порядке, в котором станция применила обновление цены. И, по сути, спроектировать класс так, чтобы минимизировать время и тд.
трюк в том, что если вам нужен идентификатор объекта, то вы можете использовать его уникальный адрес. Приводился такой пример:
struct StationClient {
[[nodiscard]] uintptr_t ClientId() const {
return reinterpret_cast<uintptr_t>(this);
}
};Выглядит с первого взгляда круто. Действительно, у каждого объекта свой уникальный адрес. Два объекта в программе не могут занимать одну ячейку памяти. Получается, что адрес можно использовать как id и это "просто работает".
Только вот сходу можно назвать кучу проблем, к которому ведет этот подход:
🔞 id объекта меняется, если он перемещается
🔞 id объекта меняется, если он копируется(должны ли такие объекты копироваться и перемещать вообще - это вопрос, но проблемы остаются)
🔞 такой айди тесно связан с временем жизни объекта и не существует о отрыве объекта в программе
🔞 id переиспользуется другими объектами и на стеке, и на куче. Как только разрушился объект, новый объект может получить тот же самый id. И как различить объекты в таком случае непонятно.
🔞 id нельзя безопасно использовать как внешний идентификатор, аля передавать в другие сервисы или сохранять в хранилища.
Я понимаю, что скорее всего на интервью, где-то в отдельных модулях приложения или в каких-то игрушечных проектах такой подход может сработать. Но в каких-то больших распределенных системах вряд ли. Хотя мир большой и вы в комментариях расскажете, где такой подход применяется на практике.
Если хочется что-то простое и на коленке сделанное, то можно использовать приватный статический(потенциально атомарный) счетчик внутри класса и при создании объекта инкрементировать его:
struct StationClient {
StationClient() {
id = Id.fetch_add(1);
}
[[nodiscard]] std::size_t ClientId() const {
return id;
}
private:
static inline std::atomic<std::size_t> Id = 0;
int id;
};При копировании, мувах Id не меняется(но это можно исправить, если надо), а чтобы новый объект заимел тот же айди нужно 100500 лет.
Однако при рестарте приложения, счетчик все еще обнуляется и будут дубли. Если вам критично, чтобы не существовало двух объектов с одинаковым id, можно использовать UUIDv7. Там 2¹²² вариантов айдишников, вероятностью совпадения при использовании uuid обычно пренебрегают.
Have your own identity. Stay cool.
#design
❤13👍9❤🔥3🔥1
std::spanstream
#опытным
Радостная весть для всех, кто пользуется iostreams! В C++23 добавлены новые классы, позволяющие работать с массивами символов как c потоками, без лишних копирований и динамических выделений памяти.
Пусть у нас есть какой-то сырой буфер данных и мы хотим безопасно считать его с удобным и привычным iostream'овским апи, при этом не делая никаких копий. Для этого ввели std::ispanstream. Он принимает С++20 std::span в качестве буфера и сам не производит никаких аллокаций и копирований:
Для чтения данных фиксированного размера std::ispanstream фактически заменяет std::istringstream, потому что гарантирует отсутствие аллокаций.
В общем, если вы как-то получили доступ к непрерывному сырому участку памяти и вам нужен удобный доступ к парсингу -
Можно даже парсить данные из файла, если использовать mmap и замаппить данные файла в виртуальное пространство процесса. Тогда можно получить std::span на данные этого файла и создать std::ispanstream, парсящий их.
Правда, если вы уже используете mmap, то скорее всего хотите какое-то производительное решение. А парсер стандартных потоков ввода-вывода мягко говоря не самый быстрый. Тот же std::from_chars будет быстрее и не менее стандартным.
Don't allocate. Stay cool.
#cpp23
#опытным
Радостная весть для всех, кто пользуется iostreams! В C++23 добавлены новые классы, позволяющие работать с массивами символов как c потоками, без лишних копирований и динамических выделений памяти.
Пусть у нас есть какой-то сырой буфер данных и мы хотим безопасно считать его с удобным и привычным iostream'овским апи, при этом не делая никаких копий. Для этого ввели std::ispanstream. Он принимает С++20 std::span в качестве буфера и сам не производит никаких аллокаций и копирований:
char input[] = "10 20 30";
std::ispanstream is{span<char>{input}};
int i;
is >> i;
ASSERT_EQUAL(10,i);
is >> i;
ASSERT_EQUAL(20,i);
is >> i;
ASSERT_EQUAL(30,i);
is >> i;
std::ispanstream может работать с любыми символами, расположенными последовательно в памяти (другими словами, со всем, от чего можно сконструировать std::span).Для чтения данных фиксированного размера std::ispanstream фактически заменяет std::istringstream, потому что гарантирует отсутствие аллокаций.
В общем, если вы как-то получили доступ к непрерывному сырому участку памяти и вам нужен удобный доступ к парсингу -
std::ispanstream вам подойдет.Можно даже парсить данные из файла, если использовать mmap и замаппить данные файла в виртуальное пространство процесса. Тогда можно получить std::span на данные этого файла и создать std::ispanstream, парсящий их.
Правда, если вы уже используете mmap, то скорее всего хотите какое-то производительное решение. А парсер стандартных потоков ввода-вывода мягко говоря не самый быстрый. Тот же std::from_chars будет быстрее и не менее стандартным.
Don't allocate. Stay cool.
#cpp23
❤14👍6🔥4🤣2
Stacktrace
#опытным
Одна из проблема исключений - непонятно, откуда оно прилетело. Ну да, по сообщению об ошибке и типу исключения обычно можно отследить нужное место в программе. Но как исполнение дошло до этой точки? А это ведь самое важное - понимание, при каком сценарии наступила исключительная ситуация.
Когда мы дебажим программу, мы видим, что привело к точке останова. В любом дебаггере есть команда аля backtrace, которая выводит визуализированный стек вызовов. Например так:
В данном случае мы видим, что ошибка произошла в функции apply_discount, которую вызвал калькулятор цены при обработке запроса платежа. То есть в данном случае мы видим четкий сценарий возникновения ошибки
И вот было бы круто видеть такую картину каждый раз, когда в catch залетает исключение. Да и в принципе в любой ситуации, когда нам интересна информация из трейса.
С++23 предоставил нам возможность конструировать кастомные исключения с описанием стека вызова внутри. Делается это через класс std::stacktrace. Работает он очень просто:
С помощью статического метода std::stacktrace::current() можно получить стек вызовов в моменте использования метода. Для типа std::stacktrace даже перегружен оператор вывода в поток, поэтому вы спокойно можете вывести сырой трейс на консоль. Вывод может быть примерно таким:
Вот примерчик, чтобы поиграться.
Теперь легко сделать свое исключение со стектрейсом и прочими прелестями.
Трейс захватываем в конструкторе, собственно ровно в том месте, откуда и летит исключение. И скипаем один фрейм сверху, чтобы не учитывать конструктор, с помощью параметра метода std::stacktrace::current. В целом, использование очень похоже на std::source_location.
Know your origin. Stay cool.
#cpp23
#опытным
Одна из проблема исключений - непонятно, откуда оно прилетело. Ну да, по сообщению об ошибке и типу исключения обычно можно отследить нужное место в программе. Но как исполнение дошло до этой точки? А это ведь самое важное - понимание, при каком сценарии наступила исключительная ситуация.
Когда мы дебажим программу, мы видим, что привело к точке останова. В любом дебаггере есть команда аля backtrace, которая выводит визуализированный стек вызовов. Например так:
(gdb) bt
#0 0x0000555555556a2a in apply_discount (order_id=1001, total=250.5) at discount.c:45
#1 0x0000555555556b10 in calculate_final_price (order=0x7fffffffde40) at order_engine.c:89
#2 0x0000555555556c45 in process_payment (order=0x7fffffffde40, method=0x555555758020 "credit") at payment.c:120
#3 0x0000555555556d8a in handle_order_request (request=0x7fffffffdf00) at api_handler.c:56
#4 0x0000555555556e20 in main () at server.c:32
В данном случае мы видим, что ошибка произошла в функции apply_discount, которую вызвал калькулятор цены при обработке запроса платежа. То есть в данном случае мы видим четкий сценарий возникновения ошибки
И вот было бы круто видеть такую картину каждый раз, когда в catch залетает исключение. Да и в принципе в любой ситуации, когда нам интересна информация из трейса.
С++23 предоставил нам возможность конструировать кастомные исключения с описанием стека вызова внутри. Делается это через класс std::stacktrace. Работает он очень просто:
#include <iostream>
#include <stacktrace>
std::stacktrace bar() {
auto trace = std::stacktrace::current();
return trace;
}
std::stacktrace foo() {
return bar();
}
int main() {
auto trace = foo();
std::cout << trace << '\n';
}
С помощью статического метода std::stacktrace::current() можно получить стек вызовов в моменте использования метода. Для типа std::stacktrace даже перегружен оператор вывода в поток, поэтому вы спокойно можете вывести сырой трейс на консоль. Вывод может быть примерно таким:
0# bar() at /app/example.cpp:5 [0x4033ee]
1# foo() at /app/example.cpp:10 [0x40340d]
2# main at /app/example.cpp:14 [0x403428]
3# <unknown> [0x74eed1a2a1c9]
4# libc_start_main [0x74eed1a2a28a]
5# start [0x403304]
Вот примерчик, чтобы поиграться.
Теперь легко сделать свое исключение со стектрейсом и прочими прелестями.
class TracedException : public std::exception {
std::string message;
std::stacktrace trace;
mutable std::string full_what; // cached formatted output
public:
explicit TracedException(std::string msg)
: message_(std::move(msg))
// Capture the stack RIGHT when the exception is created
// skip = 1 means skip this constructor from the trace
, trace_(std::stacktrace::current(/skip=/1))
{}
const std::stacktrace& trace() const noexcept {
return trace_;
}
const char* what() const noexcept override {
if (full_what_.empty()) {
full_what_ = message_ + "\n\n--- Stack Trace ---\n"
+ std::to_string(trace_);
}
return full_what_.c_str();
}
};Трейс захватываем в конструкторе, собственно ровно в том месте, откуда и летит исключение. И скипаем один фрейм сверху, чтобы не учитывать конструктор, с помощью параметра метода std::stacktrace::current. В целом, использование очень похоже на std::source_location.
Know your origin. Stay cool.
#cpp23
❤12👍6🔥5😁2
Stacktrace. Tips
#опытным
Чтобы полноценно работать со стандартными трейсами, нужно знать несколько вещей:
✅ Чтобы фича заработала с новыми версиями gcc и clang, вам нужно залинковать либу
✅ Собирайте код с дебаг символами. Если хотите видеть трейсы и получать из них полезную информацию об именах функций и файлов, вместо прочтения чистых адресов, то надо собирать в дебаге. Итого полная команда сборки:
✅ Да, вы можете включать оптимизации компилятора и добавить в бинарник дебаг символы одновременно. Оптимизации конечно могу сильно преобразовывать потенциальные трейсы за счет инлайнинга, но без оптимизаций мы не можем.
✅ Дебаг символы обычно занимают довольно внушительный объем и не всегда хочется их тянуть везде вместе с бинарем. В таких случаях можно использовать утилиту
Ну или можно собирать код с флагом -g1, который включает только самую необходимую отладочную информацию
✅ Мы рассматривали стектрейсы в разрезе исключений, но их конечно же можно использовать и отдельно от них.
Однако стоит помнить, что получение трейса - не бесплатная штука. Логи-то не бесплатный, а проход по стеку вызовов и сбор информации из дебаг символов - очень затратная штука. Не стоит использовать трейсы в нагруженных частях программы. Только в ошибочных путях программы в некритичном коде.
✅ Сам класс стектрейсов предоставляет много гибкости. Можно отдельно проходиться по каждому фрейму и в принципе контролировать количество захватываемых фреймов:
Трейсы скорее всего рано или поздно заедут в каком-то виде в стандарт, чтобы их можно было использовать со стандартными исключениями и конктейнерами. Поэтому полезно же сейчас понимать, как это работает под капотом и как этим правильно пользоваться.
Know your origin. Stay cool.
#cpp23
#опытным
Чтобы полноценно работать со стандартными трейсами, нужно знать несколько вещей:
✅ Чтобы фича заработала с новыми версиями gcc и clang, вам нужно залинковать либу
-lstdc++exp. В туториалах обычно используется линковка с -lstdc++_libbacktrace, которая не работает на свежих версиях компиляторов✅ Собирайте код с дебаг символами. Если хотите видеть трейсы и получать из них полезную информацию об именах функций и файлов, вместо прочтения чистых адресов, то надо собирать в дебаге. Итого полная команда сборки:
g++ -std=c++23 -g -lstdc++exp -O2 main.cpp -o app
✅ Да, вы можете включать оптимизации компилятора и добавить в бинарник дебаг символы одновременно. Оптимизации конечно могу сильно преобразовывать потенциальные трейсы за счет инлайнинга, но без оптимизаций мы не можем.
✅ Дебаг символы обычно занимают довольно внушительный объем и не всегда хочется их тянуть везде вместе с бинарем. В таких случаях можно использовать утилиту
strip и вырезать из бинаря отдельный файл с дебаг символами. При обнаружении проблемы с программой можно в дебаггере подгрузить файл с символами и наслаждаться прелестями человекочитаемой отладки.Ну или можно собирать код с флагом -g1, который включает только самую необходимую отладочную информацию
✅ Мы рассматривали стектрейсы в разрезе исключений, но их конечно же можно использовать и отдельно от них.
int foo(int a, int b) {
if (b == 0) {
LOG_ERROR << "Second operand is zero. Trace:\n" << std::stacktrace::current();
}
return a / b;
}Однако стоит помнить, что получение трейса - не бесплатная штука. Логи-то не бесплатный, а проход по стеку вызовов и сбор информации из дебаг символов - очень затратная штука. Не стоит использовать трейсы в нагруженных частях программы. Только в ошибочных путях программы в некритичном коде.
✅ Сам класс стектрейсов предоставляет много гибкости. Можно отдельно проходиться по каждому фрейму и в принципе контролировать количество захватываемых фреймов:
void inspectTrace(const std::stacktrace& trace) {
for (const auto& frame : trace) {
std::cout << "Function : " << frame.description()
<< '\n'
<< "Source file : " << frame.source_file()
<< '\n'
<< "Line : " << frame.source_line()
<< '\n'
<< "---\n";
}
}
// Capture at most 10 frames except of the last one.
auto trace = std::stacktrace::current(/skip=/1, /max_depth=/10);Трейсы скорее всего рано или поздно заедут в каком-то виде в стандарт, чтобы их можно было использовать со стандартными исключениями и конктейнерами. Поэтому полезно же сейчас понимать, как это работает под капотом и как этим правильно пользоваться.
Know your origin. Stay cool.
#cpp23
🔥11❤7👍4
Откуда spurious wakeup на кондваре?
#опытным
У кондваров есть метод std::condition_variable::wait, который по документации блокирует исполнение потока до тех пор, пока не придет уведомление или не произойдет тн spurious wakeup(ложное пробуждение). Поэтому нам говорят, что нужно ставить wait в цикл while:
или использовать перегрузку, передавая в кондвар предикат:
Но что это за такие ложные пробуждения, из-за которых на нужно крутиться в цикле?
По сути, ложными пробуждениями можно назвать любую ситуацию, когда поток просыпается и условие предиката неудовлетворяется. Со стороны кажется, что поток просто в рандомный момент сам проснулся, когда условия еще не создались для него. Но это не совсем так. Давайте покопаемся в причинах.
👉🏿 Поток может проснуться без уведомления. Звучит как баг или признаки рождения скайнета, но все прозаичнее. На реальных системах есть свои реальные причины такого поведения.
В линуксе раньше для управления потоками(запуск, остановка и тд) система использовала сигналы. В такой модели условная переменная физически могла быть разбужена сигналом, предназначенным для совершенно другой цели — например, менеджер потоков решил передать потоку команду, а сигнал попал в тот же обработчик, что и пробуждение от
POSIX стандарт должен был учитывать эту особенность, поэтому прям в доке прописал возможность пробуждения без сигнала.
Свежих версиях линукса такой ситуации уже не может быть и из pthread'ной функции ожидания cv управление не вернется в юзерспейс при отправке любых сигналов.
Но наследие осталось.
👉🏿 А вот тут уже реальная проблема с race condition. Поток реально получил сигнал на пробуждение, проснулся, начал выполняться и увидел, что условие ложно. Потому что по сути какой-то другой поток уже успел обработать появившееся событие. Это может произойти по многим причинам, но может быть вот такая ситуация:
1. Поток A спит на
2. Поток B меняет условие и вызывает
3. Поток A просыпается, но ещё не запланирован шедулером.
4. Поток C успевает изменить условие обратно (забрал задачу из очереди, сбросил флаг).
5. Поток A наконец получает процессор, проверяет условие — оно
или такая
1. Поток A спит на
2. Поток B меняет условие и вызывает
3. Поток A просыпается, но ещё не запланирован шедулером.
4. Поток B успевает изменить условие еще раз(или несколько).
5. Поток A наконец получает процессор, проверяет условие — оно
Да, в вашем конкретном случае такого может и не произойти просто по логике программы. А можно не думать и просто использовать цикл с кондваром, хуже точно не будет.
Спасибо, @mike_hd, за вопрос в чате и идею для поста. И всем комментаторам, вовлекшимся в обсуждение, за предоставление фактуры по вопросу.
Don't get stolen. Stay cool.
#concurrency
#опытным
У кондваров есть метод std::condition_variable::wait, который по документации блокирует исполнение потока до тех пор, пока не придет уведомление или не произойдет тн spurious wakeup(ложное пробуждение). Поэтому нам говорят, что нужно ставить wait в цикл while:
while (!pred())
cv.wait(lock);
или использовать перегрузку, передавая в кондвар предикат:
cv.wait(lock, pred);
Но что это за такие ложные пробуждения, из-за которых на нужно крутиться в цикле?
По сути, ложными пробуждениями можно назвать любую ситуацию, когда поток просыпается и условие предиката неудовлетворяется. Со стороны кажется, что поток просто в рандомный момент сам проснулся, когда условия еще не создались для него. Но это не совсем так. Давайте покопаемся в причинах.
👉🏿 Поток может проснуться без уведомления. Звучит как баг или признаки рождения скайнета, но все прозаичнее. На реальных системах есть свои реальные причины такого поведения.
В линуксе раньше для управления потоками(запуск, остановка и тд) система использовала сигналы. В такой модели условная переменная физически могла быть разбужена сигналом, предназначенным для совершенно другой цели — например, менеджер потоков решил передать потоку команду, а сигнал попал в тот же обработчик, что и пробуждение от
cond_signal. POSIX стандарт должен был учитывать эту особенность, поэтому прям в доке прописал возможность пробуждения без сигнала.
Свежих версиях линукса такой ситуации уже не может быть и из pthread'ной функции ожидания cv управление не вернется в юзерспейс при отправке любых сигналов.
Но наследие осталось.
👉🏿 А вот тут уже реальная проблема с race condition. Поток реально получил сигнал на пробуждение, проснулся, начал выполняться и увидел, что условие ложно. Потому что по сути какой-то другой поток уже успел обработать появившееся событие. Это может произойти по многим причинам, но может быть вот такая ситуация:
1. Поток A спит на
cond_wait.2. Поток B меняет условие и вызывает
notify_one.3. Поток A просыпается, но ещё не запланирован шедулером.
4. Поток C успевает изменить условие обратно (забрал задачу из очереди, сбросил флаг).
5. Поток A наконец получает процессор, проверяет условие — оно
false. Это называется stolen wakeupили такая
1. Поток A спит на
cond_wait.2. Поток B меняет условие и вызывает
notify_one.3. Поток A просыпается, но ещё не запланирован шедулером.
4. Поток B успевает изменить условие еще раз(или несколько).
5. Поток A наконец получает процессор, проверяет условие — оно
false. То есть событие было фактически потеряно.Да, в вашем конкретном случае такого может и не произойти просто по логике программы. А можно не думать и просто использовать цикл с кондваром, хуже точно не будет.
Спасибо, @mike_hd, за вопрос в чате и идею для поста. И всем комментаторам, вовлекшимся в обсуждение, за предоставление фактуры по вопросу.
Don't get stolen. Stay cool.
#concurrency
👍12❤8🔥3🤣3