diff --git a/.gitignore b/.gitignore index 311284d2..74140d1b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ /ds4flash.gguf /TODO.md /gguf/ +/hf-partial/ +/runs/ *.o *.dSYM/ /misc/ diff --git a/ds4.c b/ds4.c index 78ddc1aa..29b2c9ff 100644 --- a/ds4.c +++ b/ds4.c @@ -2089,6 +2089,13 @@ typedef struct { ds4_tensor *ffn_gate_exps; ds4_tensor *ffn_up_exps; ds4_tensor *ffn_down_exps; + ds4_tensor *ffn_gate_exps_bitlift_q4; + ds4_tensor *ffn_up_exps_bitlift_q4; + ds4_tensor *ffn_down_exps_bitlift_q4; + ds4_tensor *ffn_exps_bitlift_q4_ids; + const ds4_model *ffn_bitlift_model; + int16_t ffn_bitlift_slot[DS4_N_EXPERT]; + uint32_t ffn_bitlift_count; ds4_tensor *ffn_gate_shexp; ds4_tensor *ffn_up_shexp; ds4_tensor *ffn_down_shexp; @@ -2352,6 +2359,130 @@ static void tensor_expect_routed_expert( } } +static void bitlift_sidecar_reset_layer(ds4_layer_weights *l) { + l->ffn_gate_exps_bitlift_q4 = NULL; + l->ffn_up_exps_bitlift_q4 = NULL; + l->ffn_down_exps_bitlift_q4 = NULL; + l->ffn_exps_bitlift_q4_ids = NULL; + l->ffn_bitlift_model = NULL; + l->ffn_bitlift_count = 0; + for (uint32_t e = 0; e < DS4_N_EXPERT; e++) { + l->ffn_bitlift_slot[e] = -1; + } +} + +static int32_t tensor_read_i32_value(const ds4_model *m, const ds4_tensor *t, uint64_t idx) { + if (idx >= t->elements) ds4_die("bitlift sidecar id index is outside tensor"); + int32_t v = 0; + memcpy(&v, m->map + t->abs_offset + idx * sizeof(v), sizeof(v)); + return v; +} + +static void tensor_expect_bitlift_ids(const ds4_tensor *t, uint32_t layer) { + if (!t) ds4_die("internal error: missing bitlift id tensor"); + if (t->type != DS4_TENSOR_I32 || t->ndim != 1) { + fprintf(stderr, + "ds4: bitlift sidecar ids in layer %u must be a 1D i32 tensor, got type=%s ndim=%u\n", + layer, + tensor_type_name(t->type), + t->ndim); + exit(1); + } + if (t->dim[0] == 0 || t->dim[0] > DS4_N_EXPERT) { + fprintf(stderr, + "ds4: bitlift sidecar ids in layer %u has invalid length %" PRIu64 "\n", + layer, + t->dim[0]); + exit(1); + } + if (t->elements != t->dim[0] || t->bytes < t->dim[0] * sizeof(int32_t)) { + fprintf(stderr, "ds4: bitlift sidecar ids in layer %u has inconsistent tensor metadata\n", layer); + exit(1); + } +} + +static bool bitlift_sidecar_try_bind_layer( + ds4_layer_weights *l, + const ds4_model *m, + uint32_t layer, + bool reset_if_absent) { + if (reset_if_absent) bitlift_sidecar_reset_layer(l); + + ds4_tensor *gate = tensor_by_namef(m, "blk.%u.ffn_gate_exps.bitlift_q4.weight", layer); + ds4_tensor *up = tensor_by_namef(m, "blk.%u.ffn_up_exps.bitlift_q4.weight", layer); + ds4_tensor *down = tensor_by_namef(m, "blk.%u.ffn_down_exps.bitlift_q4.weight", layer); + ds4_tensor *ids = tensor_by_namef(m, "blk.%u.ffn_exps.bitlift_q4.ids", layer); + + const bool has_gate = gate != NULL; + const bool has_up = up != NULL; + const bool has_down = down != NULL; + const bool has_ids = ids != NULL; + const uint32_t present = (uint32_t)has_gate + (uint32_t)has_up + (uint32_t)has_down + (uint32_t)has_ids; + if (present == 0) return false; + if (present != 4) { + fprintf(stderr, + "ds4: incomplete bitlift Q4 sidecar tensor set in layer %u " + "(gate=%u up=%u down=%u ids=%u)\n", + layer, + has_gate, + has_up, + has_down, + has_ids); + exit(1); + } + + tensor_expect_bitlift_ids(ids, layer); + const uint32_t n_sidecar = (uint32_t)ids->dim[0]; + tensor_expect_layout(gate, DS4_TENSOR_Q4_K, 3, DS4_N_EMBD, DS4_N_FF_EXP, n_sidecar); + tensor_expect_layout(up, DS4_TENSOR_Q4_K, 3, DS4_N_EMBD, DS4_N_FF_EXP, n_sidecar); + tensor_expect_layout(down, DS4_TENSOR_Q4_K, 3, DS4_N_FF_EXP, DS4_N_EMBD, n_sidecar); + + bitlift_sidecar_reset_layer(l); + l->ffn_gate_exps_bitlift_q4 = gate; + l->ffn_up_exps_bitlift_q4 = up; + l->ffn_down_exps_bitlift_q4 = down; + l->ffn_exps_bitlift_q4_ids = ids; + l->ffn_bitlift_model = m; + for (uint32_t slot = 0; slot < n_sidecar; slot++) { + const int32_t expert = tensor_read_i32_value(m, ids, slot); + if (expert < 0 || expert >= (int32_t)DS4_N_EXPERT) { + fprintf(stderr, + "ds4: bitlift sidecar layer %u has invalid expert id %d at slot %u\n", + layer, + expert, + slot); + exit(1); + } + if (l->ffn_bitlift_slot[expert] >= 0) { + fprintf(stderr, + "ds4: bitlift sidecar layer %u repeats expert id %d\n", + layer, + expert); + exit(1); + } + l->ffn_bitlift_slot[expert] = (int16_t)slot; + } + l->ffn_bitlift_count = n_sidecar; + return true; +} + +static void bitlift_sidecar_bind_layer( + ds4_layer_weights *l, + const ds4_model *m, + uint32_t layer) { + (void)bitlift_sidecar_try_bind_layer(l, m, layer, true); +} + +static uint32_t weights_bind_bitlift_sidecar(ds4_weights *w, const ds4_model *m) { + uint32_t layers = 0; + for (uint32_t il = 0; il < DS4_N_LAYER; il++) { + if (bitlift_sidecar_try_bind_layer(&w->layer[il], m, il, false)) { + layers++; + } + } + return layers; +} + /* Verify every tensor type and dimension used by the specialized pipeline. * After this succeeds, inference code can rely on fixed DS4 constants. */ static void weights_validate_layout(const ds4_weights *w) { @@ -2695,6 +2826,7 @@ static void weights_bind(ds4_weights *w, const ds4_model *m) { l->ffn_gate_exps = required_tensorf(m, "blk.%u.ffn_gate_exps.weight", il); l->ffn_up_exps = required_tensorf(m, "blk.%u.ffn_up_exps.weight", il); l->ffn_down_exps = required_tensorf(m, "blk.%u.ffn_down_exps.weight", il); + bitlift_sidecar_bind_layer(l, m, il); l->ffn_gate_shexp = required_tensorf(m, "blk.%u.ffn_gate_shexp.weight", il); l->ffn_up_shexp = required_tensorf(m, "blk.%u.ffn_up_shexp.weight", il); l->ffn_down_shexp = required_tensorf(m, "blk.%u.ffn_down_shexp.weight", il); @@ -2720,6 +2852,7 @@ static void mtp_weights_bind(ds4_mtp_weights *w, const ds4_model *m) { w->norm = required_tensor(m, "mtp.0.norm.weight"); ds4_layer_weights *l = &w->block; + bitlift_sidecar_reset_layer(l); l->hc_attn_fn = required_tensor(m, "mtp.0.hc_attn_fn.weight"); l->hc_attn_scale = required_tensor(m, "mtp.0.hc_attn_scale.weight"); l->hc_attn_base = required_tensor(m, "mtp.0.hc_attn_base.weight"); @@ -8224,11 +8357,19 @@ typedef struct { ds4_gpu_tensor *router_probs; ds4_gpu_tensor *router_selected; ds4_gpu_tensor *router_weights; + ds4_gpu_tensor *trace_router_selected; + ds4_gpu_tensor *trace_router_weights; ds4_gpu_tensor *routed_gate; ds4_gpu_tensor *routed_up; ds4_gpu_tensor *routed_mid; ds4_gpu_tensor *routed_down; ds4_gpu_tensor *routed_out; + ds4_gpu_tensor *bitlift_slot_maps; + ds4_gpu_tensor *bitlift_base_selected; + ds4_gpu_tensor *bitlift_base_weights; + ds4_gpu_tensor *bitlift_side_selected; + ds4_gpu_tensor *bitlift_side_weights; + ds4_gpu_tensor *bitlift_tmp_out; ds4_gpu_tensor *ffn_out; ds4_gpu_tensor *after_ffn_hc; ds4_gpu_tensor *output_pre; @@ -8291,6 +8432,11 @@ typedef struct { ds4_gpu_tensor *batch_router_probs; ds4_gpu_tensor *batch_router_selected; ds4_gpu_tensor *batch_router_weights; + ds4_gpu_tensor *bitlift_batch_base_selected; + ds4_gpu_tensor *bitlift_batch_base_weights; + ds4_gpu_tensor *bitlift_batch_side_selected; + ds4_gpu_tensor *bitlift_batch_side_weights; + ds4_gpu_tensor *bitlift_batch_tmp_out; ds4_gpu_tensor *batch_routed_gate; ds4_gpu_tensor *batch_routed_up; ds4_gpu_tensor *batch_routed_mid; @@ -8299,6 +8445,7 @@ typedef struct { bool batch_routed_mid_is_f16; ds4_gpu_tensor *batch_ffn_out; bool materialize_ffn_out; + bool trace_decode_routes; ds4_gpu_tensor *directional_steering_dirs; float directional_steering_attn_scale; float directional_steering_ffn_scale; @@ -8315,6 +8462,11 @@ static void metal_graph_free(ds4_gpu_graph *g) { ds4_gpu_tensor_free(g->batch_routed_mid); ds4_gpu_tensor_free(g->batch_routed_up); ds4_gpu_tensor_free(g->batch_routed_gate); + ds4_gpu_tensor_free(g->bitlift_batch_tmp_out); + ds4_gpu_tensor_free(g->bitlift_batch_side_weights); + ds4_gpu_tensor_free(g->bitlift_batch_side_selected); + ds4_gpu_tensor_free(g->bitlift_batch_base_weights); + ds4_gpu_tensor_free(g->bitlift_batch_base_selected); ds4_gpu_tensor_free(g->batch_router_weights); ds4_gpu_tensor_free(g->batch_router_selected); ds4_gpu_tensor_free(g->batch_router_probs); @@ -8367,12 +8519,20 @@ static void metal_graph_free(ds4_gpu_graph *g) { ds4_gpu_tensor_free(g->after_ffn_hc); ds4_gpu_tensor_free(g->ffn_out); ds4_gpu_tensor_free(g->routed_out); + ds4_gpu_tensor_free(g->bitlift_tmp_out); + ds4_gpu_tensor_free(g->bitlift_side_weights); + ds4_gpu_tensor_free(g->bitlift_side_selected); + ds4_gpu_tensor_free(g->bitlift_base_weights); + ds4_gpu_tensor_free(g->bitlift_base_selected); + ds4_gpu_tensor_free(g->bitlift_slot_maps); ds4_gpu_tensor_free(g->routed_down); ds4_gpu_tensor_free(g->routed_mid); ds4_gpu_tensor_free(g->routed_up); ds4_gpu_tensor_free(g->routed_gate); ds4_gpu_tensor_free(g->router_weights); ds4_gpu_tensor_free(g->router_selected); + ds4_gpu_tensor_free(g->trace_router_weights); + ds4_gpu_tensor_free(g->trace_router_selected); ds4_gpu_tensor_free(g->router_probs); ds4_gpu_tensor_free(g->router_logits); ds4_gpu_tensor_free(g->shared_out); @@ -8858,11 +9018,23 @@ static bool metal_graph_alloc_raw_cap( g->router_probs = ds4_gpu_tensor_alloc(DS4_N_EXPERT * sizeof(float)); g->router_selected = ds4_gpu_tensor_alloc(DS4_N_EXPERT_USED * sizeof(int)); g->router_weights = ds4_gpu_tensor_alloc(DS4_N_EXPERT_USED * sizeof(float)); + g->trace_router_selected = ds4_gpu_tensor_alloc((uint64_t)DS4_N_LAYER * + DS4_N_EXPERT_USED * + sizeof(int)); + g->trace_router_weights = ds4_gpu_tensor_alloc((uint64_t)DS4_N_LAYER * + DS4_N_EXPERT_USED * + sizeof(float)); g->routed_gate = ds4_gpu_tensor_alloc((uint64_t)DS4_N_EXPERT_USED * routed_mid_dim * sizeof(float)); g->routed_up = ds4_gpu_tensor_alloc((uint64_t)DS4_N_EXPERT_USED * routed_mid_dim * sizeof(float)); g->routed_mid = ds4_gpu_tensor_alloc((uint64_t)DS4_N_EXPERT_USED * routed_mid_dim * sizeof(float)); g->routed_down = ds4_gpu_tensor_alloc((uint64_t)DS4_N_EXPERT_USED * DS4_N_EMBD * sizeof(float)); g->routed_out = ds4_gpu_tensor_alloc((uint64_t)DS4_N_EMBD * sizeof(float)); + g->bitlift_slot_maps = ds4_gpu_tensor_alloc((uint64_t)DS4_N_LAYER * DS4_N_EXPERT * sizeof(int32_t)); + g->bitlift_base_selected = ds4_gpu_tensor_alloc(DS4_N_EXPERT_USED * sizeof(int)); + g->bitlift_base_weights = ds4_gpu_tensor_alloc(DS4_N_EXPERT_USED * sizeof(float)); + g->bitlift_side_selected = ds4_gpu_tensor_alloc(DS4_N_EXPERT_USED * sizeof(int)); + g->bitlift_side_weights = ds4_gpu_tensor_alloc(DS4_N_EXPERT_USED * sizeof(float)); + g->bitlift_tmp_out = ds4_gpu_tensor_alloc((uint64_t)DS4_N_EMBD * sizeof(float)); g->after_ffn_hc = ds4_gpu_tensor_alloc(hc_dim * sizeof(float)); g->output_pre = ds4_gpu_tensor_alloc((uint64_t)DS4_N_HC * sizeof(float)); g->output_weights = ds4_gpu_tensor_alloc((uint64_t)DS4_N_HC * sizeof(float)); @@ -8925,6 +9097,11 @@ static bool metal_graph_alloc_raw_cap( g->batch_router_probs = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT * sizeof(float)); g->batch_router_selected = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT_USED * sizeof(int)); g->batch_router_weights = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT_USED * sizeof(float)); + g->bitlift_batch_base_selected = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT_USED * sizeof(int)); + g->bitlift_batch_base_weights = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT_USED * sizeof(float)); + g->bitlift_batch_side_selected = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT_USED * sizeof(int)); + g->bitlift_batch_side_weights = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT_USED * sizeof(float)); + g->bitlift_batch_tmp_out = ds4_gpu_tensor_alloc(pc * DS4_N_EMBD * sizeof(float)); g->batch_routed_gate = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT_USED * routed_mid_dim * sizeof(float)); g->batch_routed_up = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT_USED * routed_mid_dim * sizeof(float)); g->batch_routed_mid = ds4_gpu_tensor_alloc(pc * DS4_N_EXPERT_USED * routed_mid_dim * sizeof(float)); @@ -8957,7 +9134,24 @@ static bool metal_graph_alloc_raw_cap( } } - const bool ok = state_init_ok && layer_cache_ok && + bool bitlift_slot_maps_ok = g->bitlift_slot_maps != NULL; + if (bitlift_slot_maps_ok) { + const uint64_t n_slots = (uint64_t)DS4_N_LAYER * DS4_N_EXPERT; + int32_t *slot_maps = xmalloc((size_t)n_slots * sizeof(slot_maps[0])); + for (uint64_t i = 0; i < n_slots; i++) slot_maps[i] = -1; + for (uint32_t il = 0; il < DS4_N_LAYER; il++) { + for (uint32_t e = 0; e < DS4_N_EXPERT; e++) { + slot_maps[(uint64_t)il * DS4_N_EXPERT + e] = weights->layer[il].ffn_bitlift_slot[e]; + } + } + bitlift_slot_maps_ok = + ds4_gpu_tensor_write(g->bitlift_slot_maps, 0, + slot_maps, + n_slots * sizeof(slot_maps[0])) != 0; + free(slot_maps); + } + + const bool ok = state_init_ok && layer_cache_ok && bitlift_slot_maps_ok && g->cur_hc && g->flat_hc && g->hc_mix && g->hc_split && g->hc_pre && g->hc_post && g->hc_comb && g->attn_cur && g->attn_norm && g->qr && g->qr_norm && @@ -8970,8 +9164,13 @@ static bool metal_graph_alloc_raw_cap( g->shared_gate && g->shared_up && g->shared_mid && g->shared_out && g->router_logits && g->router_probs && g->router_selected && g->router_weights && + g->trace_router_selected && g->trace_router_weights && g->routed_gate && g->routed_up && g->routed_mid && g->routed_down && g->routed_out && + g->bitlift_slot_maps && + g->bitlift_base_selected && g->bitlift_base_weights && + g->bitlift_side_selected && g->bitlift_side_weights && + g->bitlift_tmp_out && g->after_ffn_hc && g->output_pre && g->output_weights && g->output_embd && g->output_norm && g->logits && @@ -8995,6 +9194,9 @@ static bool metal_graph_alloc_raw_cap( g->batch_shared_mid && g->batch_shared_out && g->batch_router_logits && g->batch_router_probs && g->batch_router_selected && g->batch_router_weights && + g->bitlift_batch_base_selected && g->bitlift_batch_base_weights && + g->bitlift_batch_side_selected && g->bitlift_batch_side_weights && + g->bitlift_batch_tmp_out && g->batch_routed_gate && g->batch_routed_up && g->batch_routed_mid && g->batch_routed_down && g->batch_routed_out; @@ -9210,6 +9412,538 @@ static bool metal_graph_matmul_plain_tensor( const ds4_gpu_tensor *x, uint64_t n_tok); +static bool metal_graph_trace_router_decode_layer(ds4_gpu_graph *g, uint32_t il) { + if (!g || !g->trace_decode_routes) return true; + if (!g->trace_router_selected || !g->trace_router_weights || + !g->router_selected || !g->router_weights) { + return false; + } + const uint64_t sel_bytes = (uint64_t)DS4_N_EXPERT_USED * sizeof(int); + const uint64_t weight_bytes = (uint64_t)DS4_N_EXPERT_USED * sizeof(float); + const uint64_t sel_off = (uint64_t)il * sel_bytes; + const uint64_t weight_off = (uint64_t)il * weight_bytes; + return ds4_gpu_tensor_copy(g->trace_router_selected, sel_off, + g->router_selected, 0, sel_bytes) != 0 && + ds4_gpu_tensor_copy(g->trace_router_weights, weight_off, + g->router_weights, 0, weight_bytes) != 0; +} + +static bool bitlift_sidecar_enabled(const ds4_layer_weights *layer) { + return layer && + layer->ffn_bitlift_count != 0 && + layer->ffn_bitlift_model != NULL && + layer->ffn_gate_exps_bitlift_q4 != NULL && + layer->ffn_up_exps_bitlift_q4 != NULL && + layer->ffn_down_exps_bitlift_q4 != NULL && + getenv("DS4_BITLIFT_DISABLE") == NULL; +} + +static bool bitlift_sidecar_trace_hits(void) { + static int cache = -1; + return metal_graph_env_flag("DS4_BITLIFT_TRACE_HITS", &cache); +} + +static bool bitlift_sidecar_cpu_partition(void) { + static int cache = -1; + return metal_graph_env_flag("DS4_BITLIFT_CPU_PARTITION", &cache) || + bitlift_sidecar_trace_hits(); +} + +static bool metal_graph_routed_moe_one_plain( + ds4_gpu_graph *g, + const ds4_model *model, + const ds4_layer_weights *layer, + ds4_gpu_tensor *out, + ds4_gpu_tensor *x, + const ds4_gpu_tensor *selected, + const ds4_gpu_tensor *weights) { + const uint64_t expert_in_dim = layer->ffn_gate_exps->dim[0]; + const uint64_t expert_mid_dim = layer->ffn_gate_exps->dim[1]; + const uint64_t down_in_dim = layer->ffn_down_exps->dim[0]; + const uint64_t routed_out_dim = layer->ffn_down_exps->dim[1]; + const uint64_t gate_row_bytes = routed_expert_row_bytes(layer->ffn_gate_exps); + const uint64_t gate_expert_bytes = expert_mid_dim * gate_row_bytes; + const uint64_t down_row_bytes = routed_expert_row_bytes(layer->ffn_down_exps); + const uint64_t down_expert_bytes = routed_out_dim * down_row_bytes; + + return ds4_gpu_routed_moe_one_tensor(out, + g->routed_gate, + g->routed_up, + g->routed_mid, + g->routed_down, + model->map, + model->size, + layer->ffn_gate_exps->abs_offset, + layer->ffn_up_exps->abs_offset, + layer->ffn_down_exps->abs_offset, + layer->ffn_gate_exps->type, + layer->ffn_down_exps->type, + gate_expert_bytes, + gate_row_bytes, + down_expert_bytes, + down_row_bytes, + DS4_N_EXPERT, + (uint32_t)expert_in_dim, + (uint32_t)down_in_dim, + (uint32_t)routed_out_dim, + selected, + weights, + DS4_N_EXPERT_USED, + DS4_SWIGLU_CLAMP_EXP, + x) != 0; +} + +static bool metal_graph_routed_moe_one_from_routes( + ds4_gpu_graph *g, + const ds4_model *model, + const ds4_layer_weights *layer, + uint32_t il, + ds4_gpu_tensor *out, + ds4_gpu_tensor *x, + const int32_t *selected_cpu, + const float *weights_cpu) { + int base_selected[DS4_N_EXPERT_USED]; + int side_selected[DS4_N_EXPERT_USED]; + float base_weights[DS4_N_EXPERT_USED]; + float side_weights[DS4_N_EXPERT_USED]; + uint32_t base_count = 0; + uint32_t side_count = 0; + + for (uint32_t i = 0; i < DS4_N_EXPERT_USED; i++) { + const int32_t expert = selected_cpu[i]; + int16_t slot = -1; + if (expert >= 0 && expert < (int32_t)DS4_N_EXPERT) { + slot = layer->ffn_bitlift_slot[expert]; + } + if (slot >= 0) { + side_selected[side_count] = slot; + side_weights[side_count] = weights_cpu[i]; + side_count++; + } else { + base_selected[base_count] = expert; + base_weights[base_count] = weights_cpu[i]; + base_count++; + } + } + + if (bitlift_sidecar_trace_hits()) { + fprintf(stderr, "ds4: bitlift route hit layer=%u base=%u sidecar=%u", il, base_count, side_count); + if (side_count != 0) { + fprintf(stderr, " side_slots="); + for (uint32_t i = 0; i < side_count; i++) { + fprintf(stderr, "%s%d", i ? "," : "", side_selected[i]); + } + } + fprintf(stderr, "\n"); + } + + if (side_count == 0) { + if (ds4_gpu_tensor_write(g->bitlift_base_selected, 0, + base_selected, base_count * sizeof(base_selected[0])) == 0 || + ds4_gpu_tensor_write(g->bitlift_base_weights, 0, + base_weights, base_count * sizeof(base_weights[0])) == 0) { + return false; + } + return ds4_gpu_routed_moe_one_tensor(out, + g->routed_gate, + g->routed_up, + g->routed_mid, + g->routed_down, + model->map, + model->size, + layer->ffn_gate_exps->abs_offset, + layer->ffn_up_exps->abs_offset, + layer->ffn_down_exps->abs_offset, + layer->ffn_gate_exps->type, + layer->ffn_down_exps->type, + layer->ffn_gate_exps->dim[1] * routed_expert_row_bytes(layer->ffn_gate_exps), + routed_expert_row_bytes(layer->ffn_gate_exps), + layer->ffn_down_exps->dim[1] * routed_expert_row_bytes(layer->ffn_down_exps), + routed_expert_row_bytes(layer->ffn_down_exps), + DS4_N_EXPERT, + (uint32_t)layer->ffn_gate_exps->dim[0], + (uint32_t)layer->ffn_down_exps->dim[0], + (uint32_t)layer->ffn_down_exps->dim[1], + g->bitlift_base_selected, + g->bitlift_base_weights, + base_count, + DS4_SWIGLU_CLAMP_EXP, + x) != 0; + } + + bool have_out = false; + if (base_count != 0) { + if (ds4_gpu_tensor_write(g->bitlift_base_selected, 0, + base_selected, base_count * sizeof(base_selected[0])) == 0 || + ds4_gpu_tensor_write(g->bitlift_base_weights, 0, + base_weights, base_count * sizeof(base_weights[0])) == 0) { + return false; + } + if (ds4_gpu_routed_moe_one_tensor(out, + g->routed_gate, + g->routed_up, + g->routed_mid, + g->routed_down, + model->map, + model->size, + layer->ffn_gate_exps->abs_offset, + layer->ffn_up_exps->abs_offset, + layer->ffn_down_exps->abs_offset, + layer->ffn_gate_exps->type, + layer->ffn_down_exps->type, + layer->ffn_gate_exps->dim[1] * routed_expert_row_bytes(layer->ffn_gate_exps), + routed_expert_row_bytes(layer->ffn_gate_exps), + layer->ffn_down_exps->dim[1] * routed_expert_row_bytes(layer->ffn_down_exps), + routed_expert_row_bytes(layer->ffn_down_exps), + DS4_N_EXPERT, + (uint32_t)layer->ffn_gate_exps->dim[0], + (uint32_t)layer->ffn_down_exps->dim[0], + (uint32_t)layer->ffn_down_exps->dim[1], + g->bitlift_base_selected, + g->bitlift_base_weights, + base_count, + DS4_SWIGLU_CLAMP_EXP, + x) == 0) { + return false; + } + have_out = true; + } + + if (ds4_gpu_tensor_write(g->bitlift_side_selected, 0, + side_selected, side_count * sizeof(side_selected[0])) == 0 || + ds4_gpu_tensor_write(g->bitlift_side_weights, 0, + side_weights, side_count * sizeof(side_weights[0])) == 0) { + return false; + } + + const ds4_model *side_model = layer->ffn_bitlift_model; + const ds4_tensor *gate = layer->ffn_gate_exps_bitlift_q4; + const ds4_tensor *up = layer->ffn_up_exps_bitlift_q4; + const ds4_tensor *down = layer->ffn_down_exps_bitlift_q4; + ds4_gpu_tensor *side_out = have_out ? g->bitlift_tmp_out : out; + if (ds4_gpu_routed_moe_one_tensor(side_out, + g->routed_gate, + g->routed_up, + g->routed_mid, + g->routed_down, + side_model->map, + side_model->size, + gate->abs_offset, + up->abs_offset, + down->abs_offset, + gate->type, + down->type, + gate->dim[1] * routed_expert_row_bytes(gate), + routed_expert_row_bytes(gate), + down->dim[1] * routed_expert_row_bytes(down), + routed_expert_row_bytes(down), + layer->ffn_bitlift_count, + (uint32_t)gate->dim[0], + (uint32_t)down->dim[0], + (uint32_t)down->dim[1], + g->bitlift_side_selected, + g->bitlift_side_weights, + side_count, + DS4_SWIGLU_CLAMP_EXP, + x) == 0) { + return false; + } + if (have_out) { + return ds4_gpu_add_tensor(out, out, g->bitlift_tmp_out, DS4_N_EMBD) != 0; + } + return true; +} + +static bool metal_graph_routed_moe_one_gpu_partition( + ds4_gpu_graph *g, + const ds4_model *model, + const ds4_layer_weights *layer, + uint32_t il, + ds4_gpu_tensor *out, + ds4_gpu_tensor *x, + const ds4_gpu_tensor *selected, + const ds4_gpu_tensor *weights) { + const uint64_t slot_off = (uint64_t)il * DS4_N_EXPERT * sizeof(int32_t); + if (ds4_gpu_bitlift_partition_routes_tensor(g->bitlift_base_selected, + g->bitlift_base_weights, + g->bitlift_side_selected, + g->bitlift_side_weights, + selected, + weights, + g->bitlift_slot_maps, + slot_off, + 1, + DS4_N_EXPERT_USED) == 0) { + return false; + } + + if (!metal_graph_routed_moe_one_plain(g, + model, + layer, + out, + x, + g->bitlift_base_selected, + g->bitlift_base_weights)) { + return false; + } + + const ds4_model *side_model = layer->ffn_bitlift_model; + const ds4_tensor *gate = layer->ffn_gate_exps_bitlift_q4; + const ds4_tensor *up = layer->ffn_up_exps_bitlift_q4; + const ds4_tensor *down = layer->ffn_down_exps_bitlift_q4; + if (ds4_gpu_routed_moe_one_tensor(g->bitlift_tmp_out, + g->routed_gate, + g->routed_up, + g->routed_mid, + g->routed_down, + side_model->map, + side_model->size, + gate->abs_offset, + up->abs_offset, + down->abs_offset, + gate->type, + down->type, + gate->dim[1] * routed_expert_row_bytes(gate), + routed_expert_row_bytes(gate), + down->dim[1] * routed_expert_row_bytes(down), + routed_expert_row_bytes(down), + layer->ffn_bitlift_count, + (uint32_t)gate->dim[0], + (uint32_t)down->dim[0], + (uint32_t)down->dim[1], + g->bitlift_side_selected, + g->bitlift_side_weights, + DS4_N_EXPERT_USED, + DS4_SWIGLU_CLAMP_EXP, + x) == 0) { + return false; + } + + return ds4_gpu_add_tensor(out, out, g->bitlift_tmp_out, DS4_N_EMBD) != 0; +} + +static bool metal_graph_routed_moe_one_maybe_bitlift( + ds4_gpu_graph *g, + const ds4_model *model, + const ds4_layer_weights *layer, + uint32_t il, + ds4_gpu_tensor *out, + ds4_gpu_tensor *x, + const ds4_gpu_tensor *selected, + const ds4_gpu_tensor *weights) { + if (!bitlift_sidecar_enabled(layer)) { + return metal_graph_routed_moe_one_plain(g, model, layer, out, x, selected, weights); + } + + if (!bitlift_sidecar_cpu_partition()) { + return metal_graph_routed_moe_one_gpu_partition(g, model, layer, il, out, x, selected, weights); + } + + int32_t selected_cpu[DS4_N_EXPERT_USED]; + float weights_cpu[DS4_N_EXPERT_USED]; + if (ds4_gpu_synchronize() == 0 || + ds4_gpu_tensor_read(selected, 0, selected_cpu, sizeof(selected_cpu)) == 0 || + ds4_gpu_tensor_read(weights, 0, weights_cpu, sizeof(weights_cpu)) == 0) { + return false; + } + + const bool ok = metal_graph_routed_moe_one_from_routes(g, model, layer, il, out, x, + selected_cpu, weights_cpu); + if (ds4_gpu_begin_commands() == 0) { + fprintf(stderr, "ds4: failed to resume Metal command batch after bitlift routed MoE\n"); + return false; + } + return ok; +} + +static bool metal_graph_routed_moe_batch_maybe_bitlift( + ds4_gpu_graph *g, + const ds4_model *model, + const ds4_layer_weights *layer, + uint32_t il, + uint32_t n_tokens) { + if (!bitlift_sidecar_enabled(layer)) { + const uint64_t expert_in_dim = layer->ffn_gate_exps->dim[0]; + const uint64_t expert_mid_dim = layer->ffn_gate_exps->dim[1]; + const uint64_t down_in_dim = layer->ffn_down_exps->dim[0]; + const uint64_t routed_out_dim = layer->ffn_down_exps->dim[1]; + const uint64_t gate_row_bytes = routed_expert_row_bytes(layer->ffn_gate_exps); + const uint64_t gate_expert_bytes = expert_mid_dim * gate_row_bytes; + const uint64_t down_row_bytes = routed_expert_row_bytes(layer->ffn_down_exps); + const uint64_t down_expert_bytes = routed_out_dim * down_row_bytes; + return ds4_gpu_routed_moe_batch_tensor(g->batch_routed_out, + g->batch_routed_gate, + g->batch_routed_up, + g->batch_routed_mid, + g->batch_routed_down, + model->map, + model->size, + layer->ffn_gate_exps->abs_offset, + layer->ffn_up_exps->abs_offset, + layer->ffn_down_exps->abs_offset, + layer->ffn_gate_exps->type, + layer->ffn_down_exps->type, + gate_expert_bytes, + gate_row_bytes, + down_expert_bytes, + down_row_bytes, + DS4_N_EXPERT, + (uint32_t)expert_in_dim, + (uint32_t)down_in_dim, + (uint32_t)routed_out_dim, + g->batch_router_selected, + g->batch_router_weights, + DS4_N_EXPERT_USED, + DS4_SWIGLU_CLAMP_EXP, + g->batch_ffn_norm, + n_tokens, + &g->batch_routed_mid_is_f16) != 0; + } + + if (!bitlift_sidecar_cpu_partition()) { + const uint64_t slot_off = (uint64_t)il * DS4_N_EXPERT * sizeof(int32_t); + if (ds4_gpu_bitlift_partition_routes_tensor(g->bitlift_batch_base_selected, + g->bitlift_batch_base_weights, + g->bitlift_batch_side_selected, + g->bitlift_batch_side_weights, + g->batch_router_selected, + g->batch_router_weights, + g->bitlift_slot_maps, + slot_off, + n_tokens, + DS4_N_EXPERT_USED) == 0) { + return false; + } + + const uint64_t expert_in_dim = layer->ffn_gate_exps->dim[0]; + const uint64_t expert_mid_dim = layer->ffn_gate_exps->dim[1]; + const uint64_t down_in_dim = layer->ffn_down_exps->dim[0]; + const uint64_t routed_out_dim = layer->ffn_down_exps->dim[1]; + const uint64_t gate_row_bytes = routed_expert_row_bytes(layer->ffn_gate_exps); + const uint64_t gate_expert_bytes = expert_mid_dim * gate_row_bytes; + const uint64_t down_row_bytes = routed_expert_row_bytes(layer->ffn_down_exps); + const uint64_t down_expert_bytes = routed_out_dim * down_row_bytes; + + if (ds4_gpu_routed_moe_batch_tensor(g->batch_routed_out, + g->batch_routed_gate, + g->batch_routed_up, + g->batch_routed_mid, + g->batch_routed_down, + model->map, + model->size, + layer->ffn_gate_exps->abs_offset, + layer->ffn_up_exps->abs_offset, + layer->ffn_down_exps->abs_offset, + layer->ffn_gate_exps->type, + layer->ffn_down_exps->type, + gate_expert_bytes, + gate_row_bytes, + down_expert_bytes, + down_row_bytes, + DS4_N_EXPERT, + (uint32_t)expert_in_dim, + (uint32_t)down_in_dim, + (uint32_t)routed_out_dim, + g->bitlift_batch_base_selected, + g->bitlift_batch_base_weights, + DS4_N_EXPERT_USED, + DS4_SWIGLU_CLAMP_EXP, + g->batch_ffn_norm, + n_tokens, + &g->batch_routed_mid_is_f16) == 0) { + return false; + } + + const ds4_model *side_model = layer->ffn_bitlift_model; + const ds4_tensor *gate = layer->ffn_gate_exps_bitlift_q4; + const ds4_tensor *up = layer->ffn_up_exps_bitlift_q4; + const ds4_tensor *down = layer->ffn_down_exps_bitlift_q4; + if (ds4_gpu_routed_moe_batch_tensor(g->bitlift_batch_tmp_out, + g->batch_routed_gate, + g->batch_routed_up, + g->batch_routed_mid, + g->batch_routed_down, + side_model->map, + side_model->size, + gate->abs_offset, + up->abs_offset, + down->abs_offset, + gate->type, + down->type, + gate->dim[1] * routed_expert_row_bytes(gate), + routed_expert_row_bytes(gate), + down->dim[1] * routed_expert_row_bytes(down), + routed_expert_row_bytes(down), + layer->ffn_bitlift_count, + (uint32_t)gate->dim[0], + (uint32_t)down->dim[0], + (uint32_t)down->dim[1], + g->bitlift_batch_side_selected, + g->bitlift_batch_side_weights, + DS4_N_EXPERT_USED, + DS4_SWIGLU_CLAMP_EXP, + g->batch_ffn_norm, + n_tokens, + &g->batch_routed_mid_is_f16) == 0) { + return false; + } + + return ds4_gpu_add_tensor(g->batch_routed_out, + g->batch_routed_out, + g->bitlift_batch_tmp_out, + n_tokens * DS4_N_EMBD) != 0; + } + + static bool warned = false; + if (!warned) { + fprintf(stderr, + "ds4: bitlift sidecar route tracing/CPU partition is enabled; " + "prefill and decode will synchronize for diagnostics\n"); + warned = true; + } + + const uint64_t route_count = (uint64_t)n_tokens * DS4_N_EXPERT_USED; + int32_t *selected_cpu = xmalloc((size_t)route_count * sizeof(selected_cpu[0])); + float *weights_cpu = xmalloc((size_t)route_count * sizeof(weights_cpu[0])); + bool ok = ds4_gpu_synchronize() != 0 && + ds4_gpu_tensor_read(g->batch_router_selected, 0, + selected_cpu, route_count * sizeof(selected_cpu[0])) != 0 && + ds4_gpu_tensor_read(g->batch_router_weights, 0, + weights_cpu, route_count * sizeof(weights_cpu[0])) != 0; + g->batch_routed_mid_is_f16 = false; + for (uint32_t i = 0; ok && i < n_tokens; i++) { + ds4_gpu_tensor *x_view = ds4_gpu_tensor_view( + g->batch_ffn_norm, + (uint64_t)i * DS4_N_EMBD * sizeof(float), + (uint64_t)DS4_N_EMBD * sizeof(float)); + ds4_gpu_tensor *out_view = ds4_gpu_tensor_view( + g->batch_routed_out, + (uint64_t)i * DS4_N_EMBD * sizeof(float), + (uint64_t)DS4_N_EMBD * sizeof(float)); + if (!x_view || !out_view) { + ok = false; + } else { + ok = metal_graph_routed_moe_one_from_routes(g, + model, + layer, + il, + out_view, + x_view, + selected_cpu + (uint64_t)i * DS4_N_EXPERT_USED, + weights_cpu + (uint64_t)i * DS4_N_EXPERT_USED); + } + ds4_gpu_tensor_free(out_view); + ds4_gpu_tensor_free(x_view); + } + free(weights_cpu); + free(selected_cpu); + if (ds4_gpu_begin_commands() == 0) { + fprintf(stderr, "ds4: failed to resume Metal command batch after bitlift routed prefill\n"); + return false; + } + return ok; +} + static bool metal_graph_encode_decode_layer( ds4_gpu_graph *g, const ds4_model *model, @@ -9230,10 +9964,7 @@ static bool metal_graph_encode_decode_layer( const uint32_t group_dim = DS4_N_HEAD_DIM * group_heads; const uint32_t rank = DS4_N_LORA_O; const uint32_t shared_dim = (uint32_t)layer->ffn_gate_shexp->dim[1]; - const uint64_t expert_in_dim = layer->ffn_gate_exps->dim[0]; - const uint64_t expert_mid_dim = layer->ffn_gate_exps->dim[1]; const uint64_t down_in_dim = layer->ffn_down_exps->dim[0]; - const uint64_t routed_out_dim = layer->ffn_down_exps->dim[1]; const bool compressed = ds4_layer_compress_ratio(il) != 0; const float freq_base = layer_rope_freq_base(il); const float freq_scale = layer_rope_freq_scale(il); @@ -9827,10 +10558,6 @@ static bool metal_graph_encode_decode_layer( if (ok) { metal_graph_debug_dump_tensor("ffn_norm", g->ffn_norm, DS4_N_EMBD, il, pos); } - const uint64_t gate_row_bytes = routed_expert_row_bytes(layer->ffn_gate_exps); - const uint64_t gate_expert_bytes = expert_mid_dim * gate_row_bytes; - const uint64_t down_row_bytes = routed_expert_row_bytes(layer->ffn_down_exps); - const uint64_t down_expert_bytes = routed_out_dim * down_row_bytes; if (ok) ok = metal_graph_matmul_plain_tensor(g->router_logits, model, layer->ffn_gate_inp, DS4_N_EMBD, DS4_N_EXPERT, g->ffn_norm, 1); if (ok) ok = ds4_gpu_router_select_tensor(g->router_selected, g->router_weights, g->router_probs, @@ -9851,24 +10578,15 @@ static bool metal_graph_encode_decode_layer( metal_graph_debug_dump_i32_tensor("ffn_moe_topk", g->router_selected, DS4_N_EXPERT_USED, il, pos); metal_graph_debug_dump_tensor("ffn_moe_weights_scaled", g->router_weights, DS4_N_EXPERT_USED, il, pos); } - if (ok) ok = ds4_gpu_routed_moe_one_tensor(g->routed_out, - g->routed_gate, - g->routed_up, - g->routed_mid, - g->routed_down, - model->map, model->size, - layer->ffn_gate_exps->abs_offset, - layer->ffn_up_exps->abs_offset, - layer->ffn_down_exps->abs_offset, - layer->ffn_gate_exps->type, - layer->ffn_down_exps->type, - gate_expert_bytes, gate_row_bytes, - down_expert_bytes, down_row_bytes, - (uint32_t)expert_in_dim, - (uint32_t)down_in_dim, - (uint32_t)routed_out_dim, - g->router_selected, g->router_weights, - DS4_N_EXPERT_USED, DS4_SWIGLU_CLAMP_EXP, g->ffn_norm) != 0; + if (ok) ok = metal_graph_trace_router_decode_layer(g, il); + if (ok) ok = metal_graph_routed_moe_one_maybe_bitlift(g, + model, + layer, + il, + g->routed_out, + g->ffn_norm, + g->router_selected, + g->router_weights); DS4_METAL_PROFILE_DECODE_STAGE("routed_moe"); if (ok) { metal_graph_debug_dump_tensor("ffn_moe_gate_clamped", g->routed_gate, @@ -12507,14 +13225,7 @@ static bool metal_graph_encode_layer_ffn_batch( const uint64_t hc_dim = (uint64_t)DS4_N_HC * DS4_N_EMBD; const uint64_t mix_hc = 2ull * DS4_N_HC + (uint64_t)DS4_N_HC * DS4_N_HC; const uint64_t shared_dim = layer->ffn_gate_shexp->dim[1]; - const uint64_t expert_in_dim = layer->ffn_gate_exps->dim[0]; - const uint64_t expert_mid_dim = layer->ffn_gate_exps->dim[1]; const uint64_t down_in_dim = layer->ffn_down_exps->dim[0]; - const uint64_t routed_out_dim = layer->ffn_down_exps->dim[1]; - const uint64_t gate_row_bytes = routed_expert_row_bytes(layer->ffn_gate_exps); - const uint64_t gate_expert_bytes = expert_mid_dim * gate_row_bytes; - const uint64_t down_row_bytes = routed_expert_row_bytes(layer->ffn_down_exps); - const uint64_t down_expert_bytes = routed_out_dim * down_row_bytes; const bool layer_stage_profile = getenv("DS4_METAL_LAYER_STAGE_PROFILE") != NULL; double layer_stage_t0 = layer_stage_profile ? now_sec() : 0.0; #define DS4_METAL_PROFILE_FFN_STAGE(name) do { \ @@ -12628,32 +13339,7 @@ static bool metal_graph_encode_layer_ffn_batch( } DS4_METAL_PROFILE_FFN_STAGE("router"); - if (ok) ok = ds4_gpu_routed_moe_batch_tensor(g->batch_routed_out, - g->batch_routed_gate, - g->batch_routed_up, - g->batch_routed_mid, - g->batch_routed_down, - model->map, - model->size, - layer->ffn_gate_exps->abs_offset, - layer->ffn_up_exps->abs_offset, - layer->ffn_down_exps->abs_offset, - layer->ffn_gate_exps->type, - layer->ffn_down_exps->type, - gate_expert_bytes, - gate_row_bytes, - down_expert_bytes, - down_row_bytes, - (uint32_t)expert_in_dim, - (uint32_t)down_in_dim, - (uint32_t)routed_out_dim, - g->batch_router_selected, - g->batch_router_weights, - DS4_N_EXPERT_USED, - DS4_SWIGLU_CLAMP_EXP, - g->batch_ffn_norm, - n_tokens, - &g->batch_routed_mid_is_f16) != 0; + if (ok) ok = metal_graph_routed_moe_batch_maybe_bitlift(g, model, layer, il, n_tokens); if (ok) { metal_graph_debug_dump_tensor("ffn_moe_gate_clamped", g->batch_routed_gate, (uint64_t)n_tokens * DS4_N_EXPERT_USED * down_in_dim, il, pos0); @@ -13004,7 +13690,7 @@ static bool metal_graph_eval_mtp_draft( * by expert: one tensor entry contains `n_expert * n_columns` floats and the * quantizer slices the vector for each expert. */ -typedef struct { +typedef struct ds4_imatrix_collector { float *gate_up_sum2; /* [layer][expert][4096] */ float *down_sum2; /* [layer][expert][2048] */ uint32_t gate_up_count[DS4_N_LAYER][DS4_N_EXPERT]; @@ -13013,7 +13699,11 @@ typedef struct { float *routed_mid_buf; uint16_t *routed_mid_f16_buf; int *selected_buf; + float *weight_buf; + int *decode_selected_buf; + float *decode_weight_buf; float *sq_tmp; + double route_weight_sum[DS4_N_LAYER][DS4_N_EXPERT]; uint32_t cap_tokens; uint64_t observed_tokens; uint64_t observed_routes; @@ -13033,9 +13723,16 @@ static bool imatrix_collector_init(ds4_imatrix_collector *c, uint32_t cap_tokens c->routed_mid_buf = xmalloc((size_t)c->cap_tokens * DS4_N_EXPERT_USED * DS4_N_FF_EXP * sizeof(c->routed_mid_buf[0])); c->routed_mid_f16_buf = xmalloc((size_t)c->cap_tokens * DS4_N_EXPERT_USED * DS4_N_FF_EXP * sizeof(c->routed_mid_f16_buf[0])); c->selected_buf = xmalloc((size_t)c->cap_tokens * DS4_N_EXPERT_USED * sizeof(c->selected_buf[0])); + c->weight_buf = xmalloc((size_t)c->cap_tokens * DS4_N_EXPERT_USED * sizeof(c->weight_buf[0])); + c->decode_selected_buf = xmalloc((size_t)DS4_N_LAYER * DS4_N_EXPERT_USED * + sizeof(c->decode_selected_buf[0])); + c->decode_weight_buf = xmalloc((size_t)DS4_N_LAYER * DS4_N_EXPERT_USED * + sizeof(c->decode_weight_buf[0])); c->sq_tmp = xmalloc((size_t)DS4_N_EMBD * sizeof(c->sq_tmp[0])); return c->gate_up_sum2 && c->down_sum2 && c->ffn_norm_buf && - c->routed_mid_buf && c->routed_mid_f16_buf && c->selected_buf && c->sq_tmp; + c->routed_mid_buf && c->routed_mid_f16_buf && c->selected_buf && + c->weight_buf && c->decode_selected_buf && c->decode_weight_buf && + c->sq_tmp; } static void imatrix_collector_free(ds4_imatrix_collector *c) { @@ -13046,6 +13743,9 @@ static void imatrix_collector_free(ds4_imatrix_collector *c) { free(c->routed_mid_buf); free(c->routed_mid_f16_buf); free(c->selected_buf); + free(c->weight_buf); + free(c->decode_selected_buf); + free(c->decode_weight_buf); free(c->sq_tmp); memset(c, 0, sizeof(*c)); } @@ -13070,12 +13770,14 @@ static bool imatrix_collect_layer_batch( const uint64_t mid_elems = (uint64_t)n_tokens * DS4_N_EXPERT_USED * DS4_N_FF_EXP; const uint64_t mid_bytes = mid_elems * (g->batch_routed_mid_is_f16 ? sizeof(uint16_t) : sizeof(float)); const uint64_t sel_bytes = (uint64_t)n_tokens * DS4_N_EXPERT_USED * sizeof(int); + const uint64_t weight_bytes = (uint64_t)n_tokens * DS4_N_EXPERT_USED * sizeof(float); void *mid_dst = g->batch_routed_mid_is_f16 ? (void *)c->routed_mid_f16_buf : (void *)c->routed_mid_buf; if (ds4_gpu_tensor_read(g->batch_ffn_norm, 0, c->ffn_norm_buf, norm_bytes) == 0 || ds4_gpu_tensor_read(g->batch_routed_mid, 0, mid_dst, mid_bytes) == 0 || - ds4_gpu_tensor_read(g->batch_router_selected, 0, c->selected_buf, sel_bytes) == 0) + ds4_gpu_tensor_read(g->batch_router_selected, 0, c->selected_buf, sel_bytes) == 0 || + ds4_gpu_tensor_read(g->batch_router_weights, 0, c->weight_buf, weight_bytes) == 0) { return false; } @@ -13087,10 +13789,12 @@ static bool imatrix_collect_layer_batch( for (uint32_t slot = 0; slot < DS4_N_EXPERT_USED; slot++) { const int expert = c->selected_buf[(size_t)t * DS4_N_EXPERT_USED + slot]; if (expert < 0 || expert >= DS4_N_EXPERT) continue; + const float weight = c->weight_buf[(size_t)t * DS4_N_EXPERT_USED + slot]; float *gate_up = imatrix_gate_up_ptr(c, il, (uint32_t)expert); for (uint32_t i = 0; i < DS4_N_EMBD; i++) gate_up[i] += c->sq_tmp[i]; c->gate_up_count[il][expert]++; + c->route_weight_sum[il][expert] += (double)weight; float *down = imatrix_down_ptr(c, il, (uint32_t)expert); const size_t mid_off = ((size_t)t * DS4_N_EXPERT_USED + slot) * DS4_N_FF_EXP; @@ -13113,6 +13817,69 @@ static bool imatrix_collect_layer_batch( return true; } +static bool imatrix_collect_decode_trace( + ds4_imatrix_collector *c, + ds4_gpu_graph *g) { + if (!c || !g) return true; + const uint64_t slots = (uint64_t)DS4_N_LAYER * DS4_N_EXPERT_USED; + if (ds4_gpu_tensor_read(g->trace_router_selected, 0, + c->decode_selected_buf, slots * sizeof(c->decode_selected_buf[0])) == 0 || + ds4_gpu_tensor_read(g->trace_router_weights, 0, + c->decode_weight_buf, slots * sizeof(c->decode_weight_buf[0])) == 0) + { + return false; + } + + for (uint32_t il = 0; il < DS4_N_LAYER; il++) { + for (uint32_t slot = 0; slot < DS4_N_EXPERT_USED; slot++) { + const size_t off = (size_t)il * DS4_N_EXPERT_USED + slot; + const int expert = c->decode_selected_buf[off]; + if (expert < 0 || expert >= DS4_N_EXPERT) continue; + c->gate_up_count[il][expert]++; + c->down_count[il][expert]++; + c->route_weight_sum[il][expert] += (double)c->decode_weight_buf[off]; + c->observed_routes++; + } + } + c->observed_tokens++; + c->chunks++; + return true; +} + +static bool metal_graph_eval_token_raw_swa_top_collect_routes( + ds4_gpu_graph *g, + const ds4_model *model, + const ds4_weights *weights, + int token, + uint32_t pos, + int *top_id, + ds4_imatrix_collector *collector) { + if (!top_id) return false; + const bool prev_trace = g->trace_decode_routes; + g->trace_decode_routes = true; + bool ok = ds4_gpu_begin_commands() != 0; + if (ok) ok = metal_graph_encode_token_raw_swa(g, model, weights, token, pos, true, true); + if (ok) { + ok = ds4_gpu_indexer_topk_tensor(g->comp_selected, + g->logits, + DS4_N_VOCAB, + 1, + 1) != 0; + } + if (ok) ok = ds4_gpu_end_commands() != 0; + g->trace_decode_routes = prev_trace; + + if (ok) ok = ds4_gpu_tensor_read(g->comp_selected, 0, top_id, sizeof(*top_id)) != 0; + if (ok) ok = imatrix_collect_decode_trace(collector, g); + if (!ok) { + g->trace_decode_routes = prev_trace; + if (ds4_gpu_synchronize() == 0) { + fprintf(stderr, "ds4: Metal synchronize after decode route top trace failure also failed\n"); + } + } + return ok; +} + static void imatrix_write_i32(FILE *fp, int32_t v) { if (fwrite(&v, sizeof(v), 1, fp) != 1) ds4_die("failed to write imatrix"); } @@ -13198,7 +13965,48 @@ static bool imatrix_collector_save( return true; } +static bool imatrix_collector_save_expert_usage( + const ds4_imatrix_collector *c, + const char *path) { + FILE *fp = fopen(path, "w"); + if (!fp) { + fprintf(stderr, "ds4: failed to open expert usage output %s: %s\n", path, strerror(errno)); + return false; + } + + fprintf(fp, "layer,expert,selected_count,weight_sum,count_share,weight_share\n"); + for (uint32_t il = 0; il < DS4_N_LAYER; il++) { + uint64_t layer_count = 0; + double layer_weight = 0.0; + for (uint32_t expert = 0; expert < DS4_N_EXPERT; expert++) { + layer_count += c->gate_up_count[il][expert]; + layer_weight += c->route_weight_sum[il][expert]; + } + + for (uint32_t expert = 0; expert < DS4_N_EXPERT; expert++) { + const uint32_t count = c->gate_up_count[il][expert]; + const double weight = c->route_weight_sum[il][expert]; + const double count_share = layer_count ? (double)count / (double)layer_count : 0.0; + const double weight_share = layer_weight > 0.0 ? weight / layer_weight : 0.0; + if (fprintf(fp, "%u,%u,%u,%.17g,%.17g,%.17g\n", + il, expert, count, weight, count_share, weight_share) < 0) + { + fprintf(stderr, "ds4: failed to write expert usage output %s\n", path); + fclose(fp); + return false; + } + } + } + + if (fclose(fp) != 0) { + fprintf(stderr, "ds4: failed to close expert usage output %s: %s\n", path, strerror(errno)); + return false; + } + return true; +} + static bool metal_graph_reset_prefill_state(ds4_gpu_graph *g) { + memset(g->layer_n_comp, 0, sizeof(g->layer_n_comp)); memset(g->layer_n_index_comp, 0, sizeof(g->layer_n_index_comp)); g->mtp_n_raw = 0; for (uint32_t il = 0; il < DS4_N_LAYER; il++) { @@ -14371,6 +15179,7 @@ struct ds4_vocab { struct ds4_engine { ds4_model model; + ds4_model bitlift_model; ds4_model mtp_model; ds4_vocab vocab; ds4_weights weights; @@ -14384,6 +15193,7 @@ struct ds4_engine { float directional_steering_ffn_scale; bool quality; bool metal_ready; + bool bitlift_ready; bool mtp_ready; }; @@ -16792,20 +17602,29 @@ static char *imatrix_trim_block(char *p, char *end) { int ds4_engine_collect_imatrix(ds4_engine *e, const char *dataset_path, const char *output_path, + const char *expert_usage_path, int ctx_size, int max_prompts, - int max_tokens) { + int max_tokens, + int decode_tokens) { #ifdef DS4_NO_GPU (void)e; (void)dataset_path; (void)output_path; + (void)expert_usage_path; (void)ctx_size; (void)max_prompts; (void)max_tokens; + (void)decode_tokens; fprintf(stderr, "ds4: imatrix collection requires a graph backend build\n"); return 1; #else - if (!e || !dataset_path || !output_path) return 1; + if (!e || !dataset_path || (!output_path && !expert_usage_path)) return 1; + if (decode_tokens < 0) return 1; + if (decode_tokens > 0 && output_path) { + fprintf(stderr, "ds4: decode expert usage tracing cannot write imatrix activation data\n"); + return 1; + } if (e->backend != DS4_BACKEND_METAL || !e->metal_ready) { fprintf(stderr, "ds4: imatrix collection currently requires --metal\n"); return 1; @@ -16817,6 +17636,7 @@ int ds4_engine_collect_imatrix(ds4_engine *e, if (!imatrix_read_text_file(dataset_path, &dataset, &dataset_len)) return 1; const ds4_model *model = &e->model; + const ds4_vocab *vocab = &e->vocab; const ds4_weights *weights = &e->weights; const uint32_t prefill_cap = metal_graph_prefill_cap_for_prompt(ctx_size); const uint32_t raw_cap = metal_graph_raw_cap_for_context(ctx_size, prefill_cap); @@ -16839,12 +17659,22 @@ int ds4_engine_collect_imatrix(ds4_engine *e, return 1; } - fprintf(stderr, - "ds4: collecting routed-MoE imatrix from %s (ctx=%d, chunk=%u)\n", - dataset_path, ctx_size, prefill_cap); + if (decode_tokens > 0) { + fprintf(stderr, + "ds4: collecting routed-MoE decode expert usage from %s (ctx=%d, chunk=%u, decode=%d)\n", + dataset_path, ctx_size, prefill_cap, decode_tokens); + } else { + fprintf(stderr, + "ds4: collecting routed-MoE calibration stats from %s (ctx=%d, chunk=%u)\n", + dataset_path, ctx_size, prefill_cap); + } int prompts_done = 0; int tokens_done = 0; + int decode_tokens_done = 0; + float *decode_logits = decode_tokens > 0 + ? xmalloc((size_t)DS4_N_VOCAB * sizeof(decode_logits[0])) + : NULL; char *cursor = dataset; const char *marker_lit = "===== DS4_IMATRIX_PROMPT"; while (*cursor) { @@ -16877,17 +17707,38 @@ int ds4_engine_collect_imatrix(ds4_engine *e, ok = metal_graph_prefill_chunked_range(&g, model, weights, &prompt, 0, (uint32_t)prompt.len, - NULL, false, + decode_logits, false, NULL, NULL, - &collector); + decode_tokens > 0 ? NULL : &collector); } else { ok = metal_graph_prefill_layer_major(&g, model, weights, &prompt, prompt.len, - NULL, false, - &collector); + decode_logits, false, + decode_tokens > 0 ? NULL : &collector); + } + if (ok && decode_tokens > 0) { + uint32_t pos = (uint32_t)prompt.len; + int local_decode = 0; + int token = sample_argmax(decode_logits, DS4_N_VOCAB); + for (int i = 0; i < decode_tokens && pos < (uint32_t)ctx_size; i++) { + if (token == vocab->eos_id) break; + int next_token = -1; + ok = metal_graph_eval_token_raw_swa_top_collect_routes(&g, + model, + weights, + token, + pos, + &next_token, + &collector); + if (!ok) break; + pos++; + local_decode++; + token = next_token; + } + decode_tokens_done += local_decode; } if (!ok) { - fprintf(stderr, "ds4: imatrix prefill failed at prompt %d\n", prompts_done + 1); + fprintf(stderr, "ds4: imatrix collection failed at prompt %d\n", prompts_done + 1); token_vec_free(&prompt); *end = saved; break; @@ -16895,11 +17746,20 @@ int ds4_engine_collect_imatrix(ds4_engine *e, prompts_done++; tokens_done += prompt.len; if (prompts_done % 10 == 0) { - fprintf(stderr, - "ds4: imatrix prompts=%d tokens=%d routes=%llu\r", - prompts_done, - tokens_done, - (unsigned long long)collector.observed_routes); + if (decode_tokens > 0) { + fprintf(stderr, + "ds4: expert usage prompts=%d prompt_tokens=%d decode_tokens=%d routes=%llu\r", + prompts_done, + tokens_done, + decode_tokens_done, + (unsigned long long)collector.observed_routes); + } else { + fprintf(stderr, + "ds4: imatrix prompts=%d tokens=%d routes=%llu\r", + prompts_done, + tokens_done, + (unsigned long long)collector.observed_routes); + } fflush(stderr); } } @@ -16913,7 +17773,7 @@ int ds4_engine_collect_imatrix(ds4_engine *e, } fputc('\n', stderr); - if (ok) { + if (ok && output_path) { ok = imatrix_collector_save(&collector, weights, output_path); if (ok) { fprintf(stderr, @@ -16924,7 +17784,29 @@ int ds4_engine_collect_imatrix(ds4_engine *e, (unsigned long long)collector.observed_routes); } } + if (ok && expert_usage_path) { + ok = imatrix_collector_save_expert_usage(&collector, expert_usage_path); + if (ok) { + if (decode_tokens > 0) { + fprintf(stderr, + "ds4: wrote expert usage %s from %d prompts, %d prompt tokens, %d decode tokens, %llu routed expert observations\n", + expert_usage_path, + prompts_done, + tokens_done, + decode_tokens_done, + (unsigned long long)collector.observed_routes); + } else { + fprintf(stderr, + "ds4: wrote expert usage %s from %d prompts, %d tokens, %llu routed expert observations\n", + expert_usage_path, + prompts_done, + tokens_done, + (unsigned long long)collector.observed_routes); + } + } + } + free(decode_logits); imatrix_collector_free(&collector); metal_graph_free(&g); free(dataset); @@ -17164,6 +18046,7 @@ int ds4_engine_first_token_test(ds4_engine *e, const ds4_tokens *prompt) { int ds4_engine_open(ds4_engine **out, const ds4_engine_options *opt) { ds4_engine *e = xcalloc(1, sizeof(*e)); e->model.fd = -1; + e->bitlift_model.fd = -1; e->mtp_model.fd = -1; e->backend = opt->backend; e->quality = opt->quality; @@ -17192,6 +18075,23 @@ int ds4_engine_open(ds4_engine **out, const ds4_engine_options *opt) { vocab_load(&e->vocab, &e->model); config_validate_model(&e->model); weights_bind(&e->weights, &e->model); + if (opt->bitlift_sidecar_path && opt->bitlift_sidecar_path[0]) { + model_open(&e->bitlift_model, opt->bitlift_sidecar_path, graph_backend, false); + const uint32_t sidecar_layers = weights_bind_bitlift_sidecar(&e->weights, &e->bitlift_model); + if (sidecar_layers == 0) { + fprintf(stderr, + "ds4: bitlift sidecar %s did not contain any usable sidecar layers\n", + opt->bitlift_sidecar_path); + model_close(&e->bitlift_model); + ds4_engine_close(e); + *out = NULL; + return 1; + } + e->bitlift_ready = true; + fprintf(stderr, "ds4: bitlift sidecar loaded: %s (layers=%u)\n", + opt->bitlift_sidecar_path, + sidecar_layers); + } if (e->backend == DS4_BACKEND_CPU && !cpu_load_directional_steering(e)) { ds4_engine_close(e); *out = NULL; @@ -17261,6 +18161,20 @@ int ds4_engine_open(ds4_engine **out, const ds4_engine_options *opt) { *out = NULL; return 1; } + if (e->bitlift_ready && + !ds4_gpu_set_model_map_range(e->bitlift_model.map, + e->bitlift_model.size, + e->bitlift_model.tensor_data_pos, + e->bitlift_model.size - e->bitlift_model.tensor_data_pos)) + { + fprintf(stderr, + "ds4: %s failed to map bitlift sidecar views; aborting startup. " + "This is commonly caused by insufficient memory or accelerator VM budget.\n", + ds4_backend_name(e->backend)); + ds4_engine_close(e); + *out = NULL; + return 1; + } if (!e->mtp_ready && !accelerator_cache_model_tensors(e->backend, &e->model)) { fprintf(stderr, "ds4: %s failed to prepare startup model cache\n", ds4_backend_name(e->backend)); @@ -17285,8 +18199,30 @@ int ds4_engine_open(ds4_engine **out, const ds4_engine_options *opt) { return 0; } +static void weights_bitlift_sidecar_summary(const ds4_weights *w) { + uint32_t layers = 0; + uint32_t expert_slots = 0; + for (uint32_t il = 0; il < DS4_N_LAYER; il++) { + const uint32_t n = w->layer[il].ffn_bitlift_count; + if (n == 0) continue; + layers++; + expert_slots += n; + } + + if (expert_slots == 0) { + printf("bitlift sidecar: none\n"); + return; + } + + printf("bitlift sidecar: layers=%u expert_slots=%u tensor_triplets=%u qtype=q4_k\n", + layers, + expert_slots, + layers * 3u); +} + void ds4_engine_summary(ds4_engine *e) { model_summary(&e->model); + weights_bitlift_sidecar_summary(&e->weights); } void ds4_engine_close(ds4_engine *e) { @@ -17295,6 +18231,7 @@ void ds4_engine_close(ds4_engine *e) { vocab_free(&e->vocab); ds4_threads_shutdown(); if (e->mtp_ready) model_close(&e->mtp_model); + if (e->bitlift_ready) model_close(&e->bitlift_model); model_close(&e->model); #ifndef DS4_NO_GPU ds4_gpu_cleanup(); diff --git a/ds4.h b/ds4.h index 74067a39..d1e9755e 100644 --- a/ds4.h +++ b/ds4.h @@ -61,6 +61,7 @@ typedef void (*ds4_session_progress_fn)(void *ud, const char *event, int current typedef struct { const char *model_path; + const char *bitlift_sidecar_path; const char *mtp_path; ds4_backend backend; int n_threads; @@ -114,9 +115,11 @@ int ds4_engine_generate_argmax(ds4_engine *e, const ds4_tokens *prompt, int ds4_engine_collect_imatrix(ds4_engine *e, const char *dataset_path, const char *output_path, + const char *expert_usage_path, int ctx_size, int max_prompts, - int max_tokens); + int max_tokens, + int decode_tokens); void ds4_engine_dump_tokens(ds4_engine *e, const ds4_tokens *tokens); int ds4_dump_text_tokenization(const char *model_path, const char *text, FILE *fp); int ds4_engine_head_test(ds4_engine *e, const ds4_tokens *prompt); diff --git a/ds4_cli.c b/ds4_cli.c index d321e4fb..c2020f5c 100644 --- a/ds4_cli.c +++ b/ds4_cli.c @@ -37,8 +37,12 @@ typedef struct { int dump_logprobs_top_k; const char *imatrix_dataset_path; const char *imatrix_output_path; + const char *expert_usage_output_path; + const char *batch_prompts_path; + const char *batch_output_path; int imatrix_max_prompts; int imatrix_max_tokens; + int expert_usage_decode_tokens; ds4_think_mode think_mode; bool head_test; bool first_token_test; @@ -84,6 +88,8 @@ static void usage(FILE *fp) { "Model and runtime:\n" " -m, --model FILE\n" " GGUF model path. Default: ds4flash.gguf\n" + " --bitlift-sidecar FILE\n" + " Optional GGUF containing compact Q4 routed-expert sidecar tensors.\n" " --mtp FILE\n" " Optional MTP support GGUF used for draft-token probes.\n" " --mtp-draft N\n" @@ -118,6 +124,10 @@ static void usage(FILE *fp) { " Prompt to generate from.\n" " --prompt-file FILE\n" " Read the prompt text from FILE.\n" + " --batch-prompts-tsv FILE\n" + " Run tab-separated rows: id, max_tokens, system, prompt. Escapes: \\n, \\t, \\r, \\\\.\n" + " --batch-output-jsonl FILE\n" + " Output JSONL for --batch-prompts-tsv without reloading the model per prompt.\n" " -sys, --system TEXT\n" " System prompt. Empty string disables the default. Default: You are a helpful assistant\n" " -n, --tokens N\n" @@ -164,6 +174,10 @@ static void usage(FILE *fp) { " Rendered DS4 prompt dataset produced by misc/imatrix_dataset.\n" " --imatrix-out FILE\n" " Collect a routed-MoE activation imatrix and write llama-compatible .dat.\n" + " --expert-usage-out FILE\n" + " Collect routed-MoE expert selection counts and router weights as CSV.\n" + " --expert-usage-decode-tokens N\n" + " Trace N greedy decode tokens per prompt instead of prefill prompt tokens.\n" " --imatrix-max-prompts N\n" " Stop imatrix collection after N prompts. Default: no prompt limit\n" " --imatrix-max-tokens N\n" @@ -714,6 +728,293 @@ static int run_logprob_dump(ds4_engine *engine, const cli_config *cfg, const ds4 return 0; } +typedef struct { + char *data; + size_t len; + size_t cap; +} cli_text_buffer; + +static bool cli_text_buffer_reserve(cli_text_buffer *b, size_t add) { + if (add > SIZE_MAX - b->len - 1) return false; + size_t need = b->len + add + 1; + if (need <= b->cap) return true; + size_t cap = b->cap ? b->cap : 4096; + while (cap < need) { + if (cap > SIZE_MAX / 2) return false; + cap *= 2; + } + char *p = realloc(b->data, cap); + if (!p) return false; + b->data = p; + b->cap = cap; + return true; +} + +static bool cli_text_buffer_append(cli_text_buffer *b, const char *s, size_t n) { + if (!cli_text_buffer_reserve(b, n)) return false; + if (n) memcpy(b->data + b->len, s, n); + b->len += n; + b->data[b->len] = '\0'; + return true; +} + +static void cli_text_buffer_free(cli_text_buffer *b) { + free(b->data); + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +static char *batch_unescape_field(const char *s) { + size_t n = strlen(s); + char *out = malloc(n + 1); + if (!out) return NULL; + size_t j = 0; + for (size_t i = 0; i < n; i++) { + if (s[i] == '\\' && i + 1 < n) { + char c = s[++i]; + if (c == 'n') out[j++] = '\n'; + else if (c == 't') out[j++] = '\t'; + else if (c == 'r') out[j++] = '\r'; + else if (c == '\\') out[j++] = '\\'; + else { + out[j++] = '\\'; + out[j++] = c; + } + } else { + out[j++] = s[i]; + } + } + out[j] = '\0'; + return out; +} + +static bool batch_split_tsv_line(char *line, char *fields[4]) { + size_t n = strlen(line); + while (n > 0 && (line[n - 1] == '\n' || line[n - 1] == '\r')) { + line[--n] = '\0'; + } + char *p = line; + for (int i = 0; i < 4; i++) { + fields[i] = p; + if (i == 3) return true; + char *tab = strchr(p, '\t'); + if (!tab) return false; + *tab = '\0'; + p = tab + 1; + } + return false; +} + +static void batch_write_error_row(FILE *out, const char *id, int row, const char *err) { + fputs("{\"id\":", out); + json_write_string(out, id ? id : "", id ? strlen(id) : 0); + fprintf(out, ",\"row\":%d,\"returncode\":1,\"error\":", row); + json_write_string(out, err ? err : "unknown error", err ? strlen(err) : 13); + fputs("}\n", out); + fflush(out); +} + +static int run_batch_generation(ds4_engine *engine, const cli_config *cfg) { + FILE *in = fopen(cfg->gen.batch_prompts_path, "rb"); + if (!in) { + fprintf(stderr, "ds4: failed to open --batch-prompts-tsv file: %s\n", + cfg->gen.batch_prompts_path); + return 1; + } + FILE *out = fopen(cfg->gen.batch_output_path, "ab"); + if (!out) { + fprintf(stderr, "ds4: failed to open --batch-output-jsonl file: %s\n", + cfg->gen.batch_output_path); + fclose(in); + return 1; + } + + ds4_session *session = NULL; + if (ds4_session_create(&session, engine, cfg->gen.ctx_size) != 0) { + fprintf(stderr, "ds4: batch generation requires a session backend\n"); + fclose(out); + fclose(in); + return 1; + } + + char *line = NULL; + size_t line_cap = 0; + ssize_t line_len = 0; + int row = 0; + int rc_all = 0; + while ((line_len = getline(&line, &line_cap, in)) >= 0) { + (void)line_len; + row++; + if (line[0] == '\n' || line[0] == '\r' || line[0] == '#') continue; + + char *fields[4] = {0}; + if (!batch_split_tsv_line(line, fields)) { + batch_write_error_row(out, "", row, "invalid TSV row"); + rc_all = 1; + continue; + } + + char *id = batch_unescape_field(fields[0]); + char *system = batch_unescape_field(fields[2]); + char *prompt_text = batch_unescape_field(fields[3]); + if (!id || !system || !prompt_text) { + batch_write_error_row(out, id ? id : "", row, "allocation failed"); + free(id); + free(system); + free(prompt_text); + rc_all = 1; + continue; + } + + char *end = NULL; + long n_predict_long = strtol(fields[1], &end, 10); + if (fields[1][0] == '\0' || *end != '\0' || + n_predict_long <= 0 || n_predict_long > INT32_MAX) + { + batch_write_error_row(out, id, row, "invalid max_tokens field"); + free(id); + free(system); + free(prompt_text); + rc_all = 1; + continue; + } + + cli_generation_options gen = cfg->gen; + gen.prompt = prompt_text; + gen.system = system; + gen.n_predict = (int)n_predict_long; + + ds4_tokens prompt = {0}; + build_prompt(engine, &gen, &prompt); + ds4_session_invalidate(session); + + char err[160] = {0}; + const double t_prefill0 = cli_now_sec(); + int rc = ds4_session_sync(session, &prompt, err, sizeof(err)); + const double t_prefill1 = cli_now_sec(); + + int generated = 0; + cli_text_buffer text = {0}; + double t_decode0 = cli_now_sec(); + double t_decode1 = t_decode0; + if (rc == 0) { + int max_tokens = gen.n_predict; + int room = ds4_session_ctx(session) - ds4_session_pos(session); + if (room <= 1) max_tokens = 0; + else if (max_tokens > room - 1) max_tokens = room - 1; + + uint64_t rng = cfg->gen.seed ? cfg->gen.seed + (uint64_t)row : + ((uint64_t)time(NULL) ^ ((uint64_t)getpid() << 32) ^ (uint64_t)clock() ^ (uint64_t)row); + t_decode0 = cli_now_sec(); + while (generated < max_tokens && !cli_interrupt_requested()) { + int token = ds4_session_sample(session, gen.temperature, 0, + gen.top_p, gen.min_p, &rng); + if (token == ds4_token_eos(engine)) break; + + int toks[17]; + int ntok = 0; + if (gen.temperature <= 0.0f && ds4_engine_mtp_draft_tokens(engine) > 1 && + getenv("DS4_MTP_SPEC_DISABLE") == NULL) { + ntok = ds4_session_eval_speculative_argmax(session, + token, + max_tokens - generated, + ds4_token_eos(engine), + toks, + (int)(sizeof(toks) / sizeof(toks[0])), + err, + sizeof(err)); + if (ntok < 0) { + rc = 1; + break; + } + } else { + if (ds4_session_eval(session, token, err, sizeof(err)) != 0) { + rc = 1; + break; + } + toks[0] = token; + ntok = 1; + } + + bool stop = false; + for (int j = 0; j < ntok; j++) { + if (toks[j] == ds4_token_eos(engine)) { + stop = true; + break; + } + size_t piece_len = 0; + char *piece = ds4_token_text(engine, toks[j], &piece_len); + if (!cli_text_buffer_append(&text, piece, piece_len)) { + free(piece); + snprintf(err, sizeof(err), "output allocation failed"); + rc = 1; + stop = true; + break; + } + free(piece); + generated++; + if (generated >= max_tokens) break; + } + if (stop || rc != 0) break; + } + t_decode1 = cli_now_sec(); + } + + const double prefill_s = t_prefill1 - t_prefill0; + const double decode_s = t_decode1 - t_decode0; + fprintf(out, "{\"id\":"); + json_write_string(out, id, strlen(id)); + fprintf(out, + ",\"row\":%d,\"returncode\":%d,\"prompt_tokens\":%d," + "\"generated_tokens\":%d,\"prefill_seconds\":%.6f," + "\"decode_seconds\":%.6f,\"prefill_tps\":%.6f," + "\"generation_tps\":%.6f,\"output\":", + row, + rc, + prompt.len, + generated, + prefill_s, + decode_s, + prefill_s > 0.0 ? (double)prompt.len / prefill_s : 0.0, + decode_s > 0.0 ? (double)generated / decode_s : 0.0); + json_write_string(out, text.data ? text.data : "", text.len); + if (rc != 0 && err[0]) { + fputs(",\"error\":", out); + json_write_string(out, err, strlen(err)); + } + fputs("}\n", out); + fflush(out); + if (rc != 0) rc_all = 1; + if (cli_interrupt_requested()) { + cli_interrupt_clear(); + rc_all = 130; + cli_text_buffer_free(&text); + ds4_tokens_free(&prompt); + free(id); + free(system); + free(prompt_text); + break; + } + + fprintf(stderr, "ds4: batch row %d complete: prefill %.2f t/s, generation %.2f t/s\n", + row, + prefill_s > 0.0 ? (double)prompt.len / prefill_s : 0.0, + decode_s > 0.0 ? (double)generated / decode_s : 0.0); + cli_text_buffer_free(&text); + ds4_tokens_free(&prompt); + free(id); + free(system); + free(prompt_text); + } + + free(line); + ds4_session_free(session); + if (fclose(out) != 0) rc_all = 1; + if (fclose(in) != 0) rc_all = 1; + return rc_all; +} + static int run_generation(ds4_engine *engine, const cli_config *cfg) { ds4_tokens prompt = {0}; build_prompt(engine, &cfg->gen, &prompt); @@ -1229,8 +1530,18 @@ static cli_config parse_options(int argc, char **argv) { c.gen.prompt = c.prompt_owned; } else if (!strcmp(arg, "-sys") || !strcmp(arg, "--system")) { c.gen.system = need_arg(&i, argc, argv, arg); + } else if (!strcmp(arg, "--batch-prompts-tsv")) { + if (c.gen.prompt) { + fprintf(stderr, "ds4: --batch-prompts-tsv cannot be combined with -p or --prompt-file\n"); + exit(2); + } + c.gen.batch_prompts_path = need_arg(&i, argc, argv, arg); + } else if (!strcmp(arg, "--batch-output-jsonl")) { + c.gen.batch_output_path = need_arg(&i, argc, argv, arg); } else if (!strcmp(arg, "-m") || !strcmp(arg, "--model")) { c.engine.model_path = need_arg(&i, argc, argv, arg); + } else if (!strcmp(arg, "--bitlift-sidecar")) { + c.engine.bitlift_sidecar_path = need_arg(&i, argc, argv, arg); } else if (!strcmp(arg, "--mtp")) { c.engine.mtp_path = need_arg(&i, argc, argv, arg); } else if (!strcmp(arg, "--mtp-draft")) { @@ -1280,6 +1591,13 @@ static cli_config parse_options(int argc, char **argv) { } else if (!strcmp(arg, "--imatrix-out")) { c.gen.imatrix_output_path = need_arg(&i, argc, argv, arg); c.engine.backend = DS4_BACKEND_METAL; + } else if (!strcmp(arg, "--expert-usage-out")) { + c.gen.expert_usage_output_path = need_arg(&i, argc, argv, arg); + c.engine.backend = DS4_BACKEND_METAL; + } else if (!strcmp(arg, "--expert-usage-decode-tokens") || + !strcmp(arg, "--expert-usage-decode")) { + c.gen.expert_usage_decode_tokens = parse_int(need_arg(&i, argc, argv, arg), arg); + c.engine.backend = DS4_BACKEND_METAL; } else if (!strcmp(arg, "--imatrix-max-prompts")) { c.gen.imatrix_max_prompts = parse_int(need_arg(&i, argc, argv, arg), arg); } else if (!strcmp(arg, "--imatrix-max-tokens")) { @@ -1323,12 +1641,41 @@ static cli_config parse_options(int argc, char **argv) { if (c.engine.directional_steering_file && !directional_steering_scale_set) { c.engine.directional_steering_ffn = 1.0f; } - if (c.gen.imatrix_output_path && !c.gen.imatrix_dataset_path) { - fprintf(stderr, "ds4: --imatrix-out requires --imatrix-dataset\n"); + if ((c.gen.imatrix_output_path || c.gen.expert_usage_output_path) && !c.gen.imatrix_dataset_path) { + fprintf(stderr, "ds4: --imatrix-out and --expert-usage-out require --imatrix-dataset\n"); + exit(2); + } + if (c.gen.batch_prompts_path && !c.gen.batch_output_path) { + fprintf(stderr, "ds4: --batch-prompts-tsv requires --batch-output-jsonl\n"); + exit(2); + } + if (c.gen.batch_output_path && !c.gen.batch_prompts_path) { + fprintf(stderr, "ds4: --batch-output-jsonl requires --batch-prompts-tsv\n"); + exit(2); + } + if (c.gen.batch_prompts_path && + (c.gen.prompt || c.gen.imatrix_dataset_path || c.inspect)) + { + fprintf(stderr, "ds4: --batch-prompts-tsv cannot be combined with prompt, imatrix, or --inspect modes\n"); + exit(2); + } + if (c.gen.imatrix_dataset_path && + !c.gen.imatrix_output_path && + !c.gen.expert_usage_output_path) + { + fprintf(stderr, "ds4: --imatrix-dataset requires --imatrix-out or --expert-usage-out\n"); + exit(2); + } + if (c.gen.expert_usage_decode_tokens < 0) { + fprintf(stderr, "ds4: --expert-usage-decode-tokens must be non-negative\n"); + exit(2); + } + if (c.gen.expert_usage_decode_tokens > 0 && !c.gen.expert_usage_output_path) { + fprintf(stderr, "ds4: --expert-usage-decode-tokens requires --expert-usage-out\n"); exit(2); } - if (c.gen.imatrix_dataset_path && !c.gen.imatrix_output_path) { - fprintf(stderr, "ds4: --imatrix-dataset requires --imatrix-out\n"); + if (c.gen.expert_usage_decode_tokens > 0 && c.gen.imatrix_output_path) { + fprintf(stderr, "ds4: decode expert usage tracing cannot be combined with --imatrix-out\n"); exit(2); } @@ -1361,13 +1708,17 @@ int main(int argc, char **argv) { int rc = 0; if (cfg.inspect) { ds4_engine_summary(engine); - } else if (cfg.gen.imatrix_output_path) { + } else if (cfg.gen.batch_prompts_path) { + rc = run_batch_generation(engine, &cfg); + } else if (cfg.gen.imatrix_output_path || cfg.gen.expert_usage_output_path) { rc = ds4_engine_collect_imatrix(engine, cfg.gen.imatrix_dataset_path, cfg.gen.imatrix_output_path, + cfg.gen.expert_usage_output_path, cfg.gen.ctx_size, cfg.gen.imatrix_max_prompts, - cfg.gen.imatrix_max_tokens); + cfg.gen.imatrix_max_tokens, + cfg.gen.expert_usage_decode_tokens); } else if (cfg.gen.prompt == NULL) { rc = run_repl(engine, &cfg); } else { diff --git a/ds4_gpu.h b/ds4_gpu.h index 94be4092..23cd6276 100644 --- a/ds4_gpu.h +++ b/ds4_gpu.h @@ -581,6 +581,18 @@ int ds4_gpu_add_tensor( const ds4_gpu_tensor *b, uint32_t n); +int ds4_gpu_bitlift_partition_routes_tensor( + ds4_gpu_tensor *base_selected, + ds4_gpu_tensor *base_weights, + ds4_gpu_tensor *side_selected, + ds4_gpu_tensor *side_weights, + const ds4_gpu_tensor *selected, + const ds4_gpu_tensor *weights, + const ds4_gpu_tensor *slot_map, + uint64_t slot_map_offset, + uint32_t n_tokens, + uint32_t n_expert); + int ds4_gpu_directional_steering_project_tensor( ds4_gpu_tensor *x, const ds4_gpu_tensor *directions, @@ -639,6 +651,7 @@ int ds4_gpu_routed_moe_one_tensor( uint64_t gate_row_bytes, uint64_t down_expert_bytes, uint64_t down_row_bytes, + uint32_t tensor_n_expert, uint32_t expert_in_dim, uint32_t expert_mid_dim, uint32_t out_dim, @@ -665,6 +678,7 @@ int ds4_gpu_routed_moe_batch_tensor( uint64_t gate_row_bytes, uint64_t down_expert_bytes, uint64_t down_row_bytes, + uint32_t tensor_n_expert, uint32_t expert_in_dim, uint32_t expert_mid_dim, uint32_t out_dim, diff --git a/ds4_metal.m b/ds4_metal.m index 759d4456..d91305c1 100644 --- a/ds4_metal.m +++ b/ds4_metal.m @@ -188,7 +188,7 @@ static void ds4_gpu_print_device_summary(void) { } #define DS4_METAL_MAX_MODEL_VIEWS 16 -#define DS4_METAL_MODEL_MAX_TENSOR_BYTES 704643072ull +#define DS4_METAL_MODEL_MAX_TENSOR_BYTES (1152ull * 1024ull * 1024ull) typedef struct { __strong id buffer; @@ -2661,6 +2661,12 @@ static int ds4_gpu_encode_rope_tail_inplace( float clamp_value; } ds4_gpu_dsv4_moe_swiglu_weight_args; +typedef struct { + uint32_t n_tokens; + uint32_t n_expert; + uint32_t n_slot_map; +} ds4_gpu_bitlift_partition_args; + /* Compile the single in-repo Metal source and create the pipelines that every * session uses. Shape-dependent kernels with function constants are built * lazily by the small ds4_gpu_get_* caches, so startup stays predictable @@ -11356,6 +11362,88 @@ int ds4_gpu_add_tensor( return 1; } +int ds4_gpu_bitlift_partition_routes_tensor( + ds4_gpu_tensor *base_selected, + ds4_gpu_tensor *base_weights, + ds4_gpu_tensor *side_selected, + ds4_gpu_tensor *side_weights, + const ds4_gpu_tensor *selected, + const ds4_gpu_tensor *weights, + const ds4_gpu_tensor *slot_map, + uint64_t slot_map_offset, + uint32_t n_tokens, + uint32_t n_expert) { + if (!g_initialized && !ds4_gpu_init()) return 0; + if (!base_selected || !base_weights || !side_selected || !side_weights || + !selected || !weights || !slot_map || + n_tokens == 0 || n_expert == 0 || n_expert > 6) { + return 0; + } + + @autoreleasepool { + id base_sel_buf = ds4_gpu_tensor_buffer(base_selected); + id base_w_buf = ds4_gpu_tensor_buffer(base_weights); + id side_sel_buf = ds4_gpu_tensor_buffer(side_selected); + id side_w_buf = ds4_gpu_tensor_buffer(side_weights); + id selected_buf = ds4_gpu_tensor_buffer(selected); + id weights_buf = ds4_gpu_tensor_buffer(weights); + id slot_map_buf = ds4_gpu_tensor_buffer(slot_map); + const uint64_t route_count = (uint64_t)n_tokens * n_expert; + const uint64_t selected_bytes = route_count * sizeof(int32_t); + const uint64_t weights_bytes = route_count * sizeof(float); + const uint64_t slot_map_bytes = (uint64_t)256u * sizeof(int32_t); + if (!base_sel_buf || !base_w_buf || !side_sel_buf || !side_w_buf || + !selected_buf || !weights_buf || !slot_map_buf || + ds4_gpu_tensor_bytes(base_selected) < selected_bytes || + ds4_gpu_tensor_bytes(side_selected) < selected_bytes || + ds4_gpu_tensor_bytes(selected) < selected_bytes || + ds4_gpu_tensor_bytes(base_weights) < weights_bytes || + ds4_gpu_tensor_bytes(side_weights) < weights_bytes || + ds4_gpu_tensor_bytes(weights) < weights_bytes || + slot_map_offset > ds4_gpu_tensor_bytes(slot_map) || + slot_map_bytes > ds4_gpu_tensor_bytes(slot_map) - slot_map_offset) { + fprintf(stderr, "ds4: Metal bitlift route partition received undersized buffers\n"); + return 0; + } + if (ds4_gpu_tensor_offset(slot_map) > NSUIntegerMax - slot_map_offset) return 0; + + id pipeline = + ds4_gpu_get_pipeline("kernel_dsv4_bitlift_partition_routes"); + if (!pipeline) return 0; + + ds4_gpu_bitlift_partition_args args = { + .n_tokens = n_tokens, + .n_expert = n_expert, + .n_slot_map = 256u, + }; + + int owned = 0; + id cb = ds4_gpu_command_buffer(&owned); + if (!cb) return 0; + + id enc = ds4_gpu_compute_encoder(cb); + [enc setComputePipelineState:pipeline]; + [enc setBytes:&args length:sizeof(args) atIndex:0]; + [enc setBuffer:selected_buf offset:ds4_gpu_tensor_offset(selected) atIndex:1]; + [enc setBuffer:weights_buf offset:ds4_gpu_tensor_offset(weights) atIndex:2]; + [enc setBuffer:slot_map_buf offset:(NSUInteger)(ds4_gpu_tensor_offset(slot_map) + slot_map_offset) atIndex:3]; + [enc setBuffer:base_sel_buf offset:ds4_gpu_tensor_offset(base_selected) atIndex:4]; + [enc setBuffer:base_w_buf offset:ds4_gpu_tensor_offset(base_weights) atIndex:5]; + [enc setBuffer:side_sel_buf offset:ds4_gpu_tensor_offset(side_selected) atIndex:6]; + [enc setBuffer:side_w_buf offset:ds4_gpu_tensor_offset(side_weights) atIndex:7]; + NSUInteger nth = pipeline.maxTotalThreadsPerThreadgroup; + if (nth > 128u) nth = 128u; + if (nth == 0) nth = 1u; + [enc dispatchThreads:MTLSizeMake((NSUInteger)route_count, 1, 1) + threadsPerThreadgroup:MTLSizeMake(nth, 1, 1)]; + ds4_gpu_end_compute_encoder(cb, enc); + + if (!ds4_gpu_finish_command_buffer(cb, owned, "bitlift route partition")) return 0; + } + + return 1; +} + typedef struct { uint32_t width; uint32_t rows; @@ -12824,6 +12912,7 @@ int ds4_gpu_routed_moe_one_tensor( uint64_t gate_row_bytes, uint64_t down_expert_bytes, uint64_t down_row_bytes, + uint32_t tensor_n_expert, uint32_t expert_in_dim, uint32_t expert_mid_dim, uint32_t out_dim, @@ -12837,7 +12926,8 @@ int ds4_gpu_routed_moe_one_tensor( n_expert == 0 || n_expert > 6) { return 0; } - if ((expert_in_dim % 256u) != 0 || (expert_mid_dim % 256u) != 0) return 0; + if ((expert_in_dim % 256u) != 0 || (expert_mid_dim % 256u) != 0 || + tensor_n_expert == 0) return 0; @autoreleasepool { id xbuf = ds4_gpu_tensor_buffer(x); @@ -12869,8 +12959,8 @@ int ds4_gpu_routed_moe_one_tensor( return 0; } - const uint64_t gate_tensor_bytes = 256ull * gate_expert_bytes; - const uint64_t down_tensor_bytes = 256ull * down_expert_bytes; + const uint64_t gate_tensor_bytes = (uint64_t)tensor_n_expert * gate_expert_bytes; + const uint64_t down_tensor_bytes = (uint64_t)tensor_n_expert * down_expert_bytes; uint64_t gate_inner = 0; uint64_t up_inner = 0; uint64_t down_inner = 0; @@ -12901,11 +12991,11 @@ int ds4_gpu_routed_moe_one_tensor( } ds4_gpu_mul_mv_id_args gate_args = - ds4_gpu_make_mul_mv_id_args(expert_in_dim, expert_mid_dim, 256, + ds4_gpu_make_mul_mv_id_args(expert_in_dim, expert_mid_dim, tensor_n_expert, gate_row_bytes, gate_expert_bytes, 1, n_expert, n_tokens, gate_nr0); ds4_gpu_mul_mv_id_args down_args = - ds4_gpu_make_mul_mv_id_args(expert_mid_dim, out_dim, 256, + ds4_gpu_make_mul_mv_id_args(expert_mid_dim, out_dim, tensor_n_expert, down_row_bytes, down_expert_bytes, n_expert, n_expert, n_tokens, down_nr0); @@ -13130,6 +13220,7 @@ int ds4_gpu_routed_moe_batch_tensor( uint64_t gate_row_bytes, uint64_t down_expert_bytes, uint64_t down_row_bytes, + uint32_t tensor_n_expert, uint32_t expert_in_dim, uint32_t expert_mid_dim, uint32_t out_dim, @@ -13145,7 +13236,8 @@ int ds4_gpu_routed_moe_batch_tensor( n_tokens == 0 || n_expert == 0 || n_expert > 6) { return 0; } - if ((expert_in_dim % 256u) != 0 || (expert_mid_dim % 256u) != 0) return 0; + if ((expert_in_dim % 256u) != 0 || (expert_mid_dim % 256u) != 0 || + tensor_n_expert == 0) return 0; @autoreleasepool { id xbuf = ds4_gpu_tensor_buffer(x); @@ -13179,8 +13271,8 @@ int ds4_gpu_routed_moe_batch_tensor( return 0; } - const uint64_t gate_tensor_bytes = 256ull * gate_expert_bytes; - const uint64_t down_tensor_bytes = 256ull * down_expert_bytes; + const uint64_t gate_tensor_bytes = (uint64_t)tensor_n_expert * gate_expert_bytes; + const uint64_t down_tensor_bytes = (uint64_t)tensor_n_expert * down_expert_bytes; uint64_t gate_inner = 0; uint64_t up_inner = 0; uint64_t down_inner = 0; @@ -13212,11 +13304,11 @@ int ds4_gpu_routed_moe_batch_tensor( } ds4_gpu_mul_mv_id_args gate_args = - ds4_gpu_make_mul_mv_id_args(expert_in_dim, expert_mid_dim, 256, + ds4_gpu_make_mul_mv_id_args(expert_in_dim, expert_mid_dim, tensor_n_expert, gate_row_bytes, gate_expert_bytes, 1, n_expert, n_tokens, gate_nr0); ds4_gpu_mul_mv_id_args down_args = - ds4_gpu_make_mul_mv_id_args(expert_mid_dim, out_dim, 256, + ds4_gpu_make_mul_mv_id_args(expert_mid_dim, out_dim, tensor_n_expert, down_row_bytes, down_expert_bytes, n_expert, n_expert, n_tokens, down_nr0); const bool use_mm_id = n_tokens >= 32u && ds4_gpu_mul_mm_id_map0_name(n_expert) != NULL; @@ -13251,13 +13343,13 @@ int ds4_gpu_routed_moe_batch_tensor( !g_quality_mode && getenv("DS4_METAL_MOE_MID_F32") == NULL; if (use_mm_id) { gate_map_args = - ds4_gpu_make_mul_mm_id_map_args(expert_in_dim, 256, 1, n_expert, n_tokens); + ds4_gpu_make_mul_mm_id_map_args(expert_in_dim, tensor_n_expert, 1, n_expert, n_tokens); gate_mm_args = - ds4_gpu_make_mul_mm_id_args(expert_in_dim, expert_mid_dim, 256, + ds4_gpu_make_mul_mm_id_args(expert_in_dim, expert_mid_dim, tensor_n_expert, gate_row_bytes, gate_expert_bytes, 1, n_expert, n_tokens); down_mm_args = - ds4_gpu_make_mul_mm_id_args_src1_size(expert_mid_dim, out_dim, 256, + ds4_gpu_make_mul_mm_id_args_src1_size(expert_mid_dim, out_dim, tensor_n_expert, down_row_bytes, down_expert_bytes, n_expert, n_expert, n_tokens, request_mid_f16 ? sizeof(uint16_t) : sizeof(float)); diff --git a/gguf-tools/deepseek4-quantize.c b/gguf-tools/deepseek4-quantize.c index 326063c8..613e24ae 100644 --- a/gguf-tools/deepseek4-quantize.c +++ b/gguf-tools/deepseek4-quantize.c @@ -1603,11 +1603,31 @@ static void write_padding(FILE *fp, size_t n) { } } +static void copy_tensor_from_template(FILE *dst_fp, FILE *tmpl_fp, const gguf_file *tmpl, + const tensor_meta *src, const char *out_path) { + if (fseeko(tmpl_fp, (off_t)(tmpl->data_offset + src->old_offset), SEEK_SET) != 0) { + die_errno("seek template GGUF", tmpl->path); + } + uint8_t buf[1024 * 1024]; + size_t remaining = src->size; + while (remaining) { + size_t chunk = remaining < sizeof(buf) ? remaining : sizeof(buf); + if (fread(buf, 1, chunk, tmpl_fp) != chunk) die_errno("read template tensor", tmpl->path); + if (fwrite(buf, 1, chunk, dst_fp) != chunk) die_errno("write tensor", out_path); + remaining -= chunk; + } +} + static void write_full_gguf(st_db *db, const gguf_file *tmpl, const output_context *out_ctx, const char *out_path, int n_experts, int n_threads, - const imatrix_store *imatrix) { + const imatrix_store *imatrix, bool copy_unchanged) { FILE *fp = fopen(out_path, "wb"); if (!fp) die_errno("open output", out_path); + FILE *tmpl_fp = NULL; + if (copy_unchanged) { + tmpl_fp = fopen(tmpl->path, "rb"); + if (!tmpl_fp) die_errno("open template GGUF", tmpl->path); + } if (fwrite("GGUF", 1, 4, fp) != 4) die("write GGUF magic failed"); write_u32(fp, tmpl->version); write_u64(fp, tmpl->n_tensors); @@ -1631,6 +1651,13 @@ static void write_full_gguf(st_db *db, const gguf_file *tmpl, const output_conte const tensor_meta *src = &tmpl->tensors[i]; const tensor_meta *dst = &out_ctx->tensors[i]; fprintf(stderr, "[%4" PRIu64 "/%4" PRIu64 "] %s -> %s\n", i + 1, out_ctx->n_tensors, dst->name, ds4q_type_name(dst->type)); + if (copy_unchanged && src->type == dst->type && src->size == dst->size) { + copy_tensor_from_template(fp, tmpl_fp, tmpl, src, out_path); + size_t padded = ds4q_pad(dst->size, out_ctx->alignment); + write_padding(fp, padded - dst->size); + fprintf(stderr, " copied %.2f MiB\n", (double)dst->size / 1048576.0); + continue; + } byte_buf data = generate_tensor(db, dst->name, src, dst->type, n_experts, n_threads, imatrix); size_t expected = dst->size; if (data.size != expected) { @@ -1643,6 +1670,7 @@ static void write_full_gguf(st_db *db, const gguf_file *tmpl, const output_conte fprintf(stderr, " generated %.2f MiB\n", (double)data.size / 1048576.0); free(data.data); } + if (tmpl_fp) fclose(tmpl_fp); fclose(fp); } @@ -1682,6 +1710,7 @@ typedef struct { bool dry_run; bool overwrite; bool imatrix_strict; + bool copy_unchanged; } params; static void usage(const char *argv0) { @@ -1697,6 +1726,7 @@ static void usage(const char *argv0) { printf(" --dry-run print output plan without reading HF tensor data\n"); printf(" --imatrix FILE legacy .dat imatrix from ds4 --imatrix-out\n"); printf(" --imatrix-strict fail if a quantized tensor has no matching imatrix vector\n"); + printf(" --copy-unchanged copy same-type tensors from template GGUF instead of regenerating\n"); printf(" --experts TYPE set routed w1/w2/w3 expert tensors to TYPE\n"); printf(" --routed-w1 TYPE routed gate expert tensor type\n"); printf(" --routed-w2 TYPE routed down expert tensor type\n"); @@ -1759,6 +1789,8 @@ static params parse_args(int argc, char **argv) { p.imatrix_file = need_value(argc, argv, &i, arg); } else if (strcmp(arg, "--imatrix-strict") == 0) { p.imatrix_strict = true; + } else if (strcmp(arg, "--copy-unchanged") == 0) { + p.copy_unchanged = true; } else if (strcmp(arg, "--experts") == 0 || strcmp(arg, "--routed") == 0) { ds4q_type t = parse_type(need_value(argc, argv, &i, arg)); p.policy.routed_w1 = p.policy.routed_w2 = p.policy.routed_w3 = t; @@ -1875,7 +1907,7 @@ int main(int argc, char **argv) { free(out_ctx.tensors); return 0; } - write_full_gguf(&db, &tmpl, &out_ctx, p.out_gguf, p.n_experts, p.n_threads, &imatrix); + write_full_gguf(&db, &tmpl, &out_ctx, p.out_gguf, p.n_experts, p.n_threads, &imatrix, p.copy_unchanged); fprintf(stderr, "wrote %s\n", p.out_gguf); db_close(&db); diff --git a/gguf-tools/imatrix/dataset/build_ds4_imatrix_dataset.py b/gguf-tools/imatrix/dataset/build_ds4_imatrix_dataset.py index eac2ef6d..c19c8140 100644 --- a/gguf-tools/imatrix/dataset/build_ds4_imatrix_dataset.py +++ b/gguf-tools/imatrix/dataset/build_ds4_imatrix_dataset.py @@ -2189,9 +2189,46 @@ def write_rendered(path: Path, rows: Iterable[Record]) -> int: encoding="utf-8") +def make_prompt_jsonl_records(path: Path, records: list[Record], modes: tuple[str, ...]) -> None: + """Render external JSONL rows that contain a plain ``prompt`` field. + + This is intentionally small and strict so calibration datasets can be kept + as task metadata JSONL while the imatrix/usage collector still receives the + fully rendered DS4 chat prompts it expects. + """ + + with path.open("r", encoding="utf-8") as f: + for lineno, line in enumerate(f, start=1): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError as exc: + raise SystemExit(f"{path}:{lineno}: invalid JSON: {exc}") from exc + prompt = row.get("prompt") + if not isinstance(prompt, str) or not prompt: + raise SystemExit(f"{path}:{lineno}: expected non-empty string field 'prompt'") + + rid = str(row.get("id") or f"prompt-jsonl-{lineno:06d}") + category = str(row.get("category") or "prompt_jsonl") + subcategory = str(row.get("subcategory") or "plain") + source = f"{subcategory}:{rid}" + messages = [ + {"role": "system", "content": "You are a helpful assistant"}, + {"role": "user", "content": prompt}, + ] + for mode in modes: + records.append(Record(rid, category, mode, source, messages, render(messages, mode))) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--out", default=None, help="Output directory. Defaults to this script's directory.") + parser.add_argument("--prompt-jsonl", default=None, + help="External JSONL containing plain calibration prompts in a 'prompt' field.") + parser.add_argument("--prompt-jsonl-mode", choices=("think", "nothink", "both"), default="nothink", + help="Rendering mode for --prompt-jsonl. Default: nothink.") args = parser.parse_args() script_dir = Path(__file__).resolve().parent @@ -2199,15 +2236,22 @@ def main() -> None: outdir = Path(args.out).resolve() if args.out else script_dir records: list[Record] = [] - make_source_records(root, records) - make_agent_records(records) - make_general_records(records) - make_programming_records(records) - make_algorithm_records(records) - make_language_records(records) - make_translation_records(records) - make_eval_reasoning_records(root, records) - make_long_context_records(root, records) + if args.prompt_jsonl: + if args.prompt_jsonl_mode == "both": + modes = ("think", "nothink") + else: + modes = (args.prompt_jsonl_mode,) + make_prompt_jsonl_records(Path(args.prompt_jsonl).resolve(), records, modes) + else: + make_source_records(root, records) + make_agent_records(records) + make_general_records(records) + make_programming_records(records) + make_algorithm_records(records) + make_language_records(records) + make_translation_records(records) + make_eval_reasoning_records(root, records) + make_long_context_records(root, records) write_outputs(outdir, records) manifest = json.loads((outdir / "manifest.json").read_text(encoding="utf-8")) diff --git a/gguf-tools/quants.c b/gguf-tools/quants.c index 4e7ce5b9..c68ef424 100644 --- a/gguf-tools/quants.c +++ b/gguf-tools/quants.c @@ -668,6 +668,21 @@ typedef struct { static ds4q_iq2_data ds4q_iq2_xxs_data; +static const uint8_t ds4q_kmask_iq2xs[8] = { + 1, 2, 4, 8, 16, 32, 64, 128 +}; + +static const uint8_t ds4q_ksigns_iq2xs[128] = { + 0, 129, 130, 3, 132, 5, 6, 135, 136, 9, 10, 139, 12, 141, 142, 15, + 144, 17, 18, 147, 20, 149, 150, 23, 24, 153, 154, 27, 156, 29, 30, 159, + 160, 33, 34, 163, 36, 165, 166, 39, 40, 169, 170, 43, 172, 45, 46, 175, + 48, 177, 178, 51, 180, 53, 54, 183, 184, 57, 58, 187, 60, 189, 190, 63, + 192, 65, 66, 195, 68, 197, 198, 71, 72, 201, 202, 75, 204, 77, 78, 207, + 80, 209, 210, 83, 212, 85, 86, 215, 216, 89, 90, 219, 92, 221, 222, 95, + 96, 225, 226, 99, 228, 101, 102, 231, 232, 105, 106, 235, 108, 237, 238, 111, + 240, 113, 114, 243, 116, 245, 246, 119, 120, 249, 250, 123, 252, 125, 126, 255, +}; + static int ds4q_iq2_compare_func(const void *left, const void *right) { const int *l = (const int *)left; const int *r = (const int *)right; @@ -1014,6 +1029,99 @@ static size_t ds4q_quantize_iq2_xxs(const float *src, void *dst, int64_t start, return (size_t)nrows * row_size; } +static void ds4q_dequantize_q2_k_row(const uint8_t *src, float *dst, int64_t ncols) { + const size_t row_size = ds4q_row_size(DS4Q_TYPE_Q2_K, ncols); + const int64_t blocks_per_row = ncols / QK_K; + for (int64_t b = 0; b < blocks_per_row; b++) { + const uint8_t *block = src + (size_t)b * ds4q_type_traits[DS4Q_TYPE_Q2_K].type_size; + const uint8_t *sc = block; + const uint8_t *qs = block + 16; + uint16_t hd, hmin; + memcpy(&hd, block + 80, sizeof(hd)); + memcpy(&hmin, block + 82, sizeof(hmin)); + const float d = ds4q_f16_to_f32(hd); + const float dmin = ds4q_f16_to_f32(hmin); + float *out = dst + (size_t)b * QK_K; + for (int i = 0; i < QK_K; i++) { + const int half = i / 128; + const int rem = i - half * 128; + const int quad = rem / 32; + const int within = rem - quad * 32; + const int scale_index = half * 8 + quad * 2 + within / 16; + const int shift = quad * 2; + const int q = (qs[half * 32 + within] >> shift) & 3; + const uint8_t sm = sc[scale_index]; + out[i] = d * (float)(sm & 0x0f) * (float)q - + dmin * (float)(sm >> 4); + } + } + (void)row_size; +} + +static void ds4q_dequantize_q4_k_row(const uint8_t *src, float *dst, int64_t ncols) { + const int64_t blocks_per_row = ncols / QK_K; + for (int64_t b = 0; b < blocks_per_row; b++) { + const uint8_t *block = src + (size_t)b * ds4q_type_traits[DS4Q_TYPE_Q4_K].type_size; + uint16_t hd, hmin; + memcpy(&hd, block, sizeof(hd)); + memcpy(&hmin, block + 2, sizeof(hmin)); + const float d = ds4q_f16_to_f32(hd); + const float dmin = ds4q_f16_to_f32(hmin); + const uint8_t *scales = block + 4; + const uint8_t *qs = block + 16; + float *out = dst + (size_t)b * QK_K; + for (int i = 0; i < QK_K; i++) { + uint8_t sc, m; + ds4q_get_scale_min_k4(i / 32, scales, &sc, &m); + const int half = i / 64; + const int within64 = i - half * 64; + const int byte_index = half * 32 + (within64 & 31); + const int q = within64 < 32 ? (qs[byte_index] & 0x0f) : (qs[byte_index] >> 4); + out[i] = d * (float)sc * (float)q - dmin * (float)m; + } + } +} + +static void ds4q_dequantize_iq2_xxs_row(const uint8_t *src, float *dst, int64_t ncols) { + ds4q_iq2_xxs_init(); + const int64_t blocks_per_row = ncols / QK_K; + for (int64_t b = 0; b < blocks_per_row; b++) { + const uint8_t *block = src + (size_t)b * ds4q_type_traits[DS4Q_TYPE_IQ2_XXS].type_size; + uint16_t hd; + memcpy(&hd, block, sizeof(hd)); + const float d = ds4q_f16_to_f32(hd); + const uint16_t *q2 = (const uint16_t *)(const void *)(block + 2); + float *out = dst + (size_t)b * QK_K; + for (int ib32 = 0; ib32 < QK_K / 32; ib32++) { + uint32_t aux32[2]; + memcpy(aux32, q2 + 4 * ib32, sizeof(aux32)); + const uint8_t *aux8 = (const uint8_t *)(const void *)aux32; + const uint32_t ls = 2 * (aux32[1] >> 28) + 1; + const float scale = 0.125f * d * (float)ls; + for (int k = 0; k < 4; k++) { + const uint8_t grid_index = aux8[k]; + const uint32_t sign_index = (aux32[1] >> (7 * k)) & 127; + const uint8_t signs = ds4q_ksigns_iq2xs[sign_index]; + const int8_t *grid = (const int8_t *)(const void *)(ds4q_iq2_xxs_data.grid + grid_index); + for (int j = 0; j < 8; j++) { + int mag; + if (grid[j] == 1) { + mag = 8; + } else if (grid[j] == 3) { + mag = 25; + } else if (grid[j] == 5) { + mag = 43; + } else { + mag = 0; + } + const int signed_q = (signs & ds4q_kmask_iq2xs[j]) ? -mag : mag; + out[ib32 * 32 + k * 8 + j] = scale * (float)signed_q; + } + } + } + } +} + const char *ds4q_type_name(ds4q_type type) { if (type < 0 || type >= DS4Q_TYPE_COUNT) return NULL; return ds4q_type_traits[type].name; @@ -1024,6 +1132,15 @@ bool ds4q_can_quantize(ds4q_type type) { return ds4q_type_traits[type].can_quantize; } +bool ds4q_can_dequantize(ds4q_type type) { + return type == DS4Q_TYPE_F32 || + type == DS4Q_TYPE_F16 || + type == DS4Q_TYPE_BF16 || + type == DS4Q_TYPE_Q2_K || + type == DS4Q_TYPE_Q4_K || + type == DS4Q_TYPE_IQ2_XXS; +} + int64_t ds4q_block_size(ds4q_type type) { if (type < 0 || type >= DS4Q_TYPE_COUNT) return 0; return ds4q_type_traits[type].block_size; @@ -1073,6 +1190,160 @@ size_t ds4q_quantize_chunk(ds4q_type type, const float *src, void *dst, return 0; } +size_t ds4q_dequantize_chunk(ds4q_type type, const void *src, float *dst, + int64_t nrows, int64_t ncols) { + if (type == DS4Q_TYPE_F32) { + const size_t n = (size_t)nrows * (size_t)ncols; + memcpy(dst, src, n * sizeof(float)); + return n * sizeof(float); + } + if (type == DS4Q_TYPE_F16) { + const uint16_t *in = (const uint16_t *)src; + const size_t n = (size_t)nrows * (size_t)ncols; + for (size_t i = 0; i < n; i++) dst[i] = ds4q_f16_to_f32(in[i]); + return n * sizeof(float); + } + if (type == DS4Q_TYPE_BF16) { + const uint16_t *in = (const uint16_t *)src; + const size_t n = (size_t)nrows * (size_t)ncols; + for (size_t i = 0; i < n; i++) dst[i] = ds4q_bf16_to_f32(in[i]); + return n * sizeof(float); + } + + const size_t row_size = ds4q_row_size(type, ncols); + const uint8_t *in = (const uint8_t *)src; + for (int64_t row = 0; row < nrows; row++) { + const uint8_t *src_row = in + (size_t)row * row_size; + float *dst_row = dst + (size_t)row * (size_t)ncols; + if (type == DS4Q_TYPE_Q2_K) { + ds4q_dequantize_q2_k_row(src_row, dst_row, ncols); + } else if (type == DS4Q_TYPE_Q4_K) { + ds4q_dequantize_q4_k_row(src_row, dst_row, ncols); + } else if (type == DS4Q_TYPE_IQ2_XXS) { + ds4q_dequantize_iq2_xxs_row(src_row, dst_row, ncols); + } else { + assert(!"unsupported DS4 dequantization source"); + return 0; + } + } + return (size_t)nrows * (size_t)ncols * sizeof(float); +} + +static float ds4q_e8m0_to_f32(uint8_t e) { + const uint32_t bits = e == 0 ? UINT32_C(0x00400000) : ((uint32_t)e << 23); + return ds4q_f32_from_bits(bits); +} + +static float ds4q_e4m3fn_to_f32(uint8_t x) { + const uint8_t abs = x & 0x7f; + const bool sign = (x & 0x80) != 0; + if (abs == 0) return sign ? -0.0f : 0.0f; + if (abs == 0x7f) return 0.0f; + const int exp = (x >> 3) & 0x0f; + const int man = x & 0x07; + const float value = exp == 0 ? ldexpf((float)man, -9) + : ldexpf(1.0f + (float)man / 8.0f, exp - 7); + return sign ? -value : value; +} + +size_t ds4q_quantize_fp4_e8m0_to_q4_k_chunk(const void *packed_fp4, + const void *e8m0_scales, + void *dst, + int64_t nrows, + int64_t packed_cols) { + static const float fp4_table[16] = { + 0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 3.0f, 4.0f, 6.0f, + 0.0f, -0.5f, -1.0f, -1.5f, -2.0f, -3.0f, -4.0f, -6.0f, + }; + + if (nrows <= 0 || packed_cols <= 0) return 0; + const int64_t ncols = packed_cols * 2; + if (ncols % 32 != 0) return 0; + const int64_t nblocks = ncols / 32; + const size_t q4_row = ds4q_row_size(DS4Q_TYPE_Q4_K, ncols); + if (!q4_row) return 0; + + const uint8_t *w = (const uint8_t *)packed_fp4; + const uint8_t *s = (const uint8_t *)e8m0_scales; + uint8_t *out = (uint8_t *)dst; + float *row = (float *)malloc((size_t)ncols * sizeof(float)); + if (!row) return 0; + + for (int64_t r = 0; r < nrows; r++) { + const uint8_t *wrow = w + (size_t)r * (size_t)packed_cols; + const uint8_t *srow = s + (size_t)r * (size_t)nblocks; + for (int64_t b = 0; b < nblocks; b++) { + const float scale = ds4q_e8m0_to_f32(srow[b]); + const uint8_t *wb = wrow + (size_t)b * 16; + float *dstb = row + (size_t)b * 32; + for (int64_t j = 0; j < 16; j++) { + const uint8_t q = wb[j]; + dstb[2 * j + 0] = fp4_table[q & 0x0f] * scale; + dstb[2 * j + 1] = fp4_table[(q >> 4) & 0x0f] * scale; + } + } + const size_t wrote = ds4q_quantize_chunk(DS4Q_TYPE_Q4_K, + row, + out + (size_t)r * q4_row, + 0, + 1, + ncols, + NULL); + if (wrote != q4_row) { + free(row); + return 0; + } + } + free(row); + return (size_t)nrows * q4_row; +} + +size_t ds4q_quantize_fp8_f32_to_q4_k_chunk(const void *fp8_e4m3, + const void *f32_scales, + void *dst, + int64_t nrows, + int64_t ncols, + int64_t scale_cols) { + enum { block_rows = 128, block_cols = 128 }; + if (nrows <= 0 || ncols <= 0 || scale_cols <= 0) return 0; + if (ncols % block_cols != 0) return 0; + if (scale_cols != ncols / block_cols) return 0; + const size_t q4_row = ds4q_row_size(DS4Q_TYPE_Q4_K, ncols); + if (!q4_row) return 0; + + const uint8_t *w = (const uint8_t *)fp8_e4m3; + const float *s = (const float *)f32_scales; + uint8_t *out = (uint8_t *)dst; + float *row = (float *)malloc((size_t)ncols * sizeof(float)); + if (!row) return 0; + + for (int64_t r = 0; r < nrows; r++) { + const uint8_t *wrow = w + (size_t)r * (size_t)ncols; + const float *srow = s + (size_t)(r / block_rows) * (size_t)scale_cols; + for (int64_t b = 0; b < scale_cols; b++) { + const float scale = srow[b]; + const uint8_t *wb = wrow + (size_t)b * block_cols; + float *dstb = row + (size_t)b * block_cols; + for (int64_t c = 0; c < block_cols; c++) { + dstb[c] = ds4q_e4m3fn_to_f32(wb[c]) * scale; + } + } + const size_t wrote = ds4q_quantize_chunk(DS4Q_TYPE_Q4_K, + row, + out + (size_t)r * q4_row, + 0, + 1, + ncols, + NULL); + if (wrote != q4_row) { + free(row); + return 0; + } + } + free(row); + return (size_t)nrows * q4_row; +} + float ds4q_f16_to_f32(uint16_t bits) { const uint32_t w = (uint32_t)bits << 16; const uint32_t sign = w & UINT32_C(0x80000000); diff --git a/gguf-tools/quants.h b/gguf-tools/quants.h index 554b7754..c499f84f 100644 --- a/gguf-tools/quants.h +++ b/gguf-tools/quants.h @@ -59,6 +59,7 @@ static inline size_t ds4q_pad(size_t x, size_t n) { const char *ds4q_type_name(ds4q_type type); bool ds4q_can_quantize(ds4q_type type); +bool ds4q_can_dequantize(ds4q_type type); int64_t ds4q_block_size(ds4q_type type); size_t ds4q_row_size(ds4q_type type, int64_t ne); bool ds4q_requires_imatrix(ds4q_type type); @@ -66,6 +67,19 @@ void ds4q_quantize_init(ds4q_type type); size_t ds4q_quantize_chunk(ds4q_type type, const float *src, void *dst, int64_t start, int64_t nrows, int64_t ncols, const float *imatrix); +size_t ds4q_dequantize_chunk(ds4q_type type, const void *src, float *dst, + int64_t nrows, int64_t ncols); +size_t ds4q_quantize_fp4_e8m0_to_q4_k_chunk(const void *packed_fp4, + const void *e8m0_scales, + void *dst, + int64_t nrows, + int64_t packed_cols); +size_t ds4q_quantize_fp8_f32_to_q4_k_chunk(const void *fp8_e4m3, + const void *f32_scales, + void *dst, + int64_t nrows, + int64_t ncols, + int64_t scale_cols); float ds4q_f16_to_f32(uint16_t bits); float ds4q_bf16_to_f32(uint16_t bits); diff --git a/metal/moe.metal b/metal/moe.metal index 65074d7d..1cad0f29 100644 --- a/metal/moe.metal +++ b/metal/moe.metal @@ -121,6 +121,48 @@ struct ds4_metal_dsv4_moe_swiglu_weight_args { float clamp_value; }; +struct ds4_metal_bitlift_partition_args { + uint32_t n_tokens; + uint32_t n_expert; + uint32_t n_slot_map; +}; + +// Split router top-k rows into base and sidecar lanes without CPU readback. +// Entries that do not belong to a lane are mapped to expert/slot 0 with weight +// 0. The downstream routed-MoE kernels can therefore keep the fixed top-6 shape +// while producing a zero contribution for inactive lanes. +kernel void kernel_dsv4_bitlift_partition_routes( + constant ds4_metal_bitlift_partition_args &args, + device const int32_t *selected, + device const float *weights, + device const int32_t *slot_map, + device int32_t *base_selected, + device float *base_weights, + device int32_t *side_selected, + device float *side_weights, + uint gid [[thread_position_in_grid]]) { + const uint total = args.n_tokens * args.n_expert; + if (gid >= total) return; + + const int32_t expert = selected[gid]; + int32_t slot = -1; + if (expert >= 0 && (uint32_t)expert < args.n_slot_map) { + slot = slot_map[expert]; + } + + if (slot >= 0) { + base_selected[gid] = 0; + base_weights[gid] = 0.0f; + side_selected[gid] = slot; + side_weights[gid] = weights[gid]; + } else { + base_selected[gid] = expert >= 0 ? expert : 0; + base_weights[gid] = weights[gid]; + side_selected[gid] = 0; + side_weights[gid] = 0.0f; + } +} + // Routed-MoE activation for the selected experts: // clamp(gate), clamp(up), silu(gate) * up * route_weight. Normal inference // does not consume gate/up after this point, so the fast path avoids writing the diff --git a/reports/ds4_bitlift_sidecar_runtime_writer_20260519.md b/reports/ds4_bitlift_sidecar_runtime_writer_20260519.md new file mode 100644 index 00000000..95b90ac2 --- /dev/null +++ b/reports/ds4_bitlift_sidecar_runtime_writer_20260519.md @@ -0,0 +1,266 @@ +# DS4 Bit-Lift Sidecar Runtime / GGUF Writer 진행 보고서 + +작성 시각: 2026-05-19 +작업 위치: `/Users/kch3dri4n/llm_provide/ds4` + +## 1. 이번 단계의 결론 + +이번 작업으로 기존의 “sidecar GGUF를 안전하게 읽고 검증하는 단계”를 넘어, 실제 Metal routed-MoE 계산 경로에서 sidecar Q4 expert slice를 선택해 계산하는 1차 런타임 경로를 구현했습니다. 또한 compact sidecar GGUF를 생성하는 writer를 추가했고, 실제 sidecar 파일을 외장 디스크에 생성해 base GGUF와 함께 로딩, inspect, nothink 생성, Think MAX 생성까지 스모크 테스트했습니다. + +단, 현재 구현은 기능 검증과 correctness 우선의 1차 경로입니다. sidecar 대상 layer에서 router top-k 결과를 CPU로 읽어 base expert와 sidecar expert를 분리한 뒤 Metal routed-MoE를 두 번 호출하고 결과를 합산하므로, 최종 고속 경로로 보려면 GPU-native route partition/remap 커널 또는 bitlift-aware fused MoE 경로가 추가로 필요합니다. + +## 2. 구현된 기능 + +### 2.1 CLI 및 engine 옵션 + +새 옵션을 추가했습니다. + +```bash +./ds4 -m ds4flash.gguf --bitlift-sidecar path/to/sidecar.gguf ... +``` + +`--bitlift-sidecar`는 base GGUF와 별도의 compact Q4 routed-expert sidecar GGUF를 로드합니다. base 모델은 그대로 두고, sidecar에 들어 있는 layer/expert만 런타임에서 Q4 sidecar tensor로 대체 계산합니다. + +### 2.2 sidecar GGUF contract + +sidecar GGUF는 다음 tensor 이름을 사용합니다. + +```text +blk.N.ffn_gate_exps.bitlift_q4.weight +blk.N.ffn_up_exps.bitlift_q4.weight +blk.N.ffn_down_exps.bitlift_q4.weight +blk.N.ffn_exps.bitlift_q4.ids +``` + +각 layer는 gate/up/down Q4_K compact tensor 3개와 expert id i32 tensor 1개를 가집니다. `ids` tensor는 compact slot index와 원래 expert id의 대응표입니다. + +### 2.3 런타임 계산 경로 + +sidecar가 있는 layer에서는 다음 순서로 동작합니다. + +1. router가 기존처럼 top-6 expert id와 weight를 계산합니다. +2. 런타임이 top-6 중 sidecar에 포함된 expert를 compact slot id로 remap합니다. +3. base expert는 기존 base GGUF tensor로 계산합니다. +4. sidecar expert는 sidecar GGUF의 Q4_K compact tensor로 계산합니다. +5. base 결과와 sidecar 결과를 더해 최종 routed-MoE 출력으로 사용합니다. + +Metal routed-MoE 함수는 기존 256 experts 고정 구조에서 벗어나, compact sidecar tensor의 expert 수를 받을 수 있도록 `tensor_n_expert` 인자를 추가했습니다. + +### 2.4 sidecar GGUF writer + +추가된 writer: + +```text +/Users/kch3dri4n/llm_provide/ds4/tools/write_bitlift_sidecar_gguf.py +``` + +주요 옵션: + +```bash +tools/write_bitlift_sidecar_gguf.py \ + --source-q4 SOURCE_Q4.gguf \ + --plan PLAN.json \ + --out OUTPUT.sidecar.gguf \ + --allow-missing-source-q4 \ + --summary SUMMARY.json +``` + +이 writer는 기존 Q4 GGUF에서 필요한 expert slice만 복사해 compact sidecar GGUF를 생성합니다. + +## 3. 생성된 sidecar 산출물 + +### 3.1 실제 sidecar 파일 + +외장 디스크에 생성된 파일: + +```text +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf +``` + +로컬 symlink: + +```text +/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf +``` + +요약: + +```text +layer_count: 5 +layers: 37, 38, 40, 41, 42 +expert_slot_count: 160 +tensor_count: 20 +file size: 약 2.1 GiB +payload size: 약 2.109 GiB +``` + +주의할 점은 이 sidecar가 “Layer10Q4 전체”가 아니라는 것입니다. 현재 사용한 `bitlift_think_priority_top32_skip_existing4` 계열 plan은 이미 4bit로 간주된 L23/L25/L28/L34/L36을 skip합니다. 따라서 Layer10Q4 source에서 실제 sidecar로 복사된 것은 추가 후보 layer인 L37/L38/L40/L41/L42의 top32 experts/layer입니다. + +### 3.2 smoke sidecar + +작은 검증용 sidecar: + +```text +/Users/kch3dri4n/llm_provide/ds4/runs/20260519_followup/sidecar_plan/bitlift_smoke_layer37_2.gguf +``` + +요약: + +```text +layer_count: 1 +expert_slot_count: 2 +file size: 약 27 MiB +``` + +## 4. 검증 결과 + +### 4.1 빌드 + +전체 빌드 통과: + +```bash +make +``` + +생성 확인: + +```text +ds4 +ds4-server +ds4-bench +ds4-eval +``` + +### 4.2 CLI 확인 + +`./ds4 --help`에서 다음 옵션이 확인되었습니다. + +```text +--bitlift-sidecar FILE + Optional GGUF containing compact Q4 routed-expert sidecar tensors. +``` + +### 4.3 inspect 확인 + +명령: + +```bash +./ds4 -m ds4flash.gguf \ + --bitlift-sidecar gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf \ + --inspect +``` + +결과: + +```text +bitlift sidecar loaded: ... (layers=5) +Metal base model mapped: 82697.67 MiB +Metal sidecar mapped: 2160.02 MiB +bitlift sidecar: layers=5 expert_slots=160 tensor_triplets=15 qtype=q4_k +``` + +### 4.4 nothink base vs sidecar 스모크 속도 + +프롬프트: + +```text +한국어로 한 문장만 답하세요. 오늘 상태는? +``` + +결과: + +| 모델 | prefill | decode/generation | 비고 | +|---|---:|---:|---| +| base | 73.19 tok/s | 32.52 tok/s | 정상 생성 | +| base + ThinkTop32 sidecar | 63.52 tok/s | 29.36 tok/s | 정상 생성 | + +sidecar는 현재 구현 기준 decode가 약 10% 느립니다. 원인은 sidecar layer에서 route id/weight를 CPU로 읽어 분기하는 1차 구현 방식입니다. + +### 4.5 Think MAX 스모크 + +명령: + +```bash +./ds4 -m ds4flash.gguf \ + --bitlift-sidecar gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf \ + --think-max --ctx 393216 \ + -p '한국어로 한 문장만 답하세요. 복잡도와 순환 알고리즘 보고서는 어떻게 시작하면 좋을까요?' \ + -n 16 --temp 0 +``` + +결과: + +```text +ctx=393216 +context buffers: 6889.71 MiB +prefill: 91.22 tok/s +generation: 30.80 tok/s +``` + +Think MAX 경로는 로딩과 생성이 정상 동작했습니다. 다만 `-n 16`의 짧은 smoke 출력은 답변 품질을 판단하기에 부족하므로, Think MAX 품질 평가는 별도 30개 이상 프롬프트로 다시 보는 것이 맞습니다. + +## 5. sidecar expert 활성화 확인 + +`DS4_BITLIFT_TRACE_HITS=1`을 추가해 실제 routed-MoE 경로에서 sidecar slot hit를 출력하도록 했습니다. + +검증 로그: + +```text +/Users/kch3dri4n/llm_provide/ds4/runs/20260519_followup/sidecar_plan/bitlift_trace_hits_smoke.log +``` + +요약: + +| layer | routed rows | sidecar top-k hits | rows with sidecar hit | sidecar top-k share | +|---:|---:|---:|---:|---:| +| 37 | 28 | 107 | 26 | 0.6369 | +| 38 | 28 | 131 | 27 | 0.7798 | +| 40 | 28 | 111 | 28 | 0.6607 | +| 41 | 28 | 102 | 27 | 0.6071 | +| 42 | 28 | 113 | 27 | 0.6726 | + +이 결과는 지목한 layer의 sidecar experts가 단순히 파일에 존재하는 것이 아니라 실제 라우팅 중 계산 경로에 들어갔음을 보여줍니다. + +## 6. 현재 한계 + +1. prefill sidecar 경로는 correctness-first per-token 방식입니다. 긴 prompt에서는 sidecar 대상 layer에서 prefill throughput이 떨어질 수 있습니다. +2. decode도 현재는 sidecar layer마다 GPU 결과를 동기화하고 route를 CPU로 읽습니다. 이 때문에 sidecar를 붙이면 decode 속도가 base보다 낮아집니다. +3. 현재 sidecar는 5개 layer만 포함합니다. 기존 4bit layer까지 포함한 완전한 Layer10Q4 equivalent sidecar는 아닙니다. +4. sidecar writer는 Q4_K slice 복사에 초점을 맞춘 도구입니다. 향후 Q6_K/Q8_0 또는 다른 quant type 혼합 sidecar까지 확장하려면 tensor type별 expert slice copy 규칙을 더 넣어야 합니다. +5. 품질 평가는 아직 smoke 수준입니다. KMMLU 300, Think MAX 30, 장문 지시문 재설계 평가는 sidecar 고속화 전/후로 나누어 다시 실행하는 것이 좋습니다. + +## 7. 다음 구현 우선순위 + +### P0: GPU-native route partition/remap + +현재 CPU readback을 없애기 위해 router top-k 결과를 GPU에서 바로 base group과 sidecar group으로 나누는 kernel을 추가해야 합니다. + +필요 산출물: + +```text +base_selected/base_weights +side_selected/side_weights +base_count/side_count +``` + +### P1: sidecar-aware fused MoE + +가능하면 base와 sidecar를 두 번 호출하고 add하는 구조가 아니라, routed-MoE kernel 내부에서 expert id별 tensor source를 선택하도록 만드는 것이 좋습니다. + +### P2: full evaluation + +고속화 후 아래 평가를 다시 실행해야 합니다. + +```text +1. KMMLU 300 +2. Think MAX 한국어 30 +3. exact-copy 확대 +4. 장문 지시문 v2/v3 +5. 영어/중국어/control 퇴화 확인 +``` + +## 8. 최종 판단 + +이번 단계는 “실사용 계산 경로가 전혀 없음”에서 “실제로 sidecar Q4 expert를 선택해 계산하고 결과를 합산하는 1차 런타임”으로 넘어간 상태입니다. 기능적으로는 의미 있는 성과가 있고, sidecar GGUF 포맷과 writer도 실제 파일 생성까지 검증되었습니다. + +다만 성능 관점에서는 아직 완성형이 아닙니다. 다음 성과 지점은 CPU readback 없는 GPU-native route remap을 구현해서 sidecar decode 속도를 base 대비 0~3% 손실 수준으로 낮추는 것입니다. diff --git a/reports/ds4_followup_eval_sidecar_plan_20260518.md b/reports/ds4_followup_eval_sidecar_plan_20260518.md new file mode 100644 index 00000000..7051540b --- /dev/null +++ b/reports/ds4_followup_eval_sidecar_plan_20260518.md @@ -0,0 +1,243 @@ +# DS4 한국어 Q4 후속 평가 및 Sidecar 설계 계획서 + +작성일: 2026-05-18 +작업 디렉터리: `/Users/kch3dri4n/llm_provide/ds4` +평가 결과 디렉터리: `/tmp/ds4-ko-cal/structured_eval_layerq4` + +## 0. 이번 턴에서 수행한 작업 + +이번 작업은 사용자의 지시에 따라 로컬 디스크를 먼저 정리한 뒤, 현행 layer 단위 Q4 모델인 `Worst5Q4`를 기준으로 품질과 속도를 다시 측정했습니다. 또한 `Mixed32`를 그대로 밀어붙이기보다, 현재 런타임에서 실제로 표현 가능한 방식과 expert-level sidecar 설계 사이의 경계를 분리했습니다. + +진행한 단계는 다음과 같습니다. + +1. 로컬 디스크 정리 및 평가 공간 확보 +2. held-out 한국어 100개로 `base` vs `Worst5Q4` 비교 +3. 영어/중국어/control 퇴화 확인 +4. exact-copy와 장문 지시문 확대 평가 +5. expert-level sidecar GGUF/runtime 설계 +6. `KR-Mixed32` 실제 생성 가능성 및 layer-Q4 대체 경로 판단 + +## 1. 로컬 디스크 관리 결과 + +초기 상태에서는 로컬 Data 볼륨 여유 공간이 약 34GiB 수준이라, 새 GGUF 생성과 재평가를 동시에 진행하기 어려웠습니다. `DeepSeek-V4-Flash` 원본 HF cache는 재다운로드 가능한 중간 산출물로 판단해 제거했고, 그 결과 로컬 여유 공간은 현재 약 182GiB 수준입니다. + +현재 주요 디스크 상태는 다음과 같습니다. + +| 위치 | 상태 | +|---|---:| +| `/` | 약 182GiB free | +| `/Volumes/Back_UP` | 약 73GiB free | +| `gguf/` | 약 170GiB | +| HF hub cache | 약 89GiB | + +중요한 점은 `/Volumes/Back_UP`에 있는 safetensors는 `JANGTQ-K` 변환본이며, 현재 `gguf-tools/deepseek4-quantize`가 기대하는 원본 `deepseek-ai/DeepSeek-V4-Flash` safetensors가 아닙니다. 즉 새 full GGUF를 다시 만들려면 원본 HF safetensors를 다시 받아야 합니다. + +## 2. 평가 대상 모델 + +| 모델 | 경로 | 크기 | +|---|---|---:| +| base | `/Users/kch3dri4n/llm_provide/ds4/ds4flash.gguf` | 80.76GiB | +| Worst5Q4 | `/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf` | 89.20GiB | + +`Worst5Q4`는 기존 2bit 계열 GGUF에서 routed expert tensor layer `L23/L25/L28/L34/L36`만 full layer Q4_K로 올린 모델입니다. expert 단위 sidecar나 Mixed32가 실제 적용된 모델은 아직 아닙니다. + +## 3. 평가 인프라 변경 + +기존에는 프롬프트마다 `./ds4` 프로세스를 새로 띄워 모델 mmap과 초기화를 반복했습니다. 이번에는 `ds4_cli.c`에 평가용 batch generation 모드를 추가했습니다. + +추가된 옵션은 다음과 같습니다. + +```text +--batch-prompts-tsv FILE +--batch-output-jsonl FILE +``` + +TSV 입력은 `idmax_tokenssystemprompt` 형식이며, 출력은 prompt별 JSONL입니다. 이 변경으로 모델을 한 번 로드한 뒤 여러 평가 프롬프트를 순차 처리할 수 있게 됐습니다. + +평가 스크립트는 다음 파일입니다. + +```text +/Users/kch3dri4n/llm_provide/ds4/tools/eval_ds4_project.py +``` + +## 4. 전체 평가 요약 + +| Suite | Model | N | Pass | Pass Rate | Avg Prefill t/s | Avg Decode t/s | +|---|---|---:|---:|---:|---:|---:| +| korean100 | base | 100 | 88 | 88.00% | 122.41 | 31.81 | +| korean100 | Worst5Q4 | 100 | 80 | 80.00% | 121.12 | 31.63 | +| control60 | base | 60 | 60 | 100.00% | 59.22 | 32.26 | +| control60 | Worst5Q4 | 60 | 60 | 100.00% | 58.77 | 31.95 | +| exact_long_extra | base | 30 | 10 | 33.33% | 136.82 | 31.62 | +| exact_long_extra | Worst5Q4 | 30 | 10 | 33.33% | 134.34 | 31.40 | + +## 5. 세부 태스크별 결과 + +| Suite | Kind | Base Pass | Worst5Q4 Pass | Base Prefill | Worst Prefill | Base Decode | Worst Decode | +|---|---|---:|---:|---:|---:|---:|---:| +| control60 | chinese | 20/20 | 20/20 | 59.27 | 58.72 | 32.22 | 31.88 | +| control60 | control_exact | 20/20 | 20/20 | 62.62 | 62.03 | 32.35 | 31.97 | +| control60 | english | 20/20 | 20/20 | 55.76 | 55.57 | 32.20 | 32.00 | +| exact_long_extra | exact | 0/20 | 0/20 | 138.83 | 136.07 | 31.66 | 31.40 | +| exact_long_extra | long | 10/10 | 10/10 | 132.79 | 130.88 | 31.53 | 31.41 | +| korean100 | daily | 16/20 | 20/20 | 112.22 | 111.22 | 31.91 | 31.72 | +| korean100 | exact | 20/20 | 20/20 | 112.63 | 110.40 | 31.97 | 31.82 | +| korean100 | long | 12/20 | 0/20 | 160.72 | 160.27 | 31.55 | 31.43 | +| korean100 | summary | 20/20 | 20/20 | 133.92 | 132.10 | 31.62 | 31.40 | +| korean100 | tech | 20/20 | 20/20 | 92.57 | 91.60 | 31.98 | 31.78 | + +핵심 해석은 다음과 같습니다. + +- `daily`, `summary`, `tech`, `korean exact-copy`는 Worst5Q4가 base와 동등하거나 더 안정적입니다. +- `control60`은 base와 Worst5Q4 모두 60/60으로 통과했습니다. 영어/중국어/control 퇴화는 이번 측정에서는 보이지 않습니다. +- 확대 exact-copy의 한글 자모 케이스는 base와 Worst5Q4 모두 0/20입니다. 이 실패는 Q4 layer 추가 때문이라기보다 모델/프롬프트/토큰화 난도 자체가 원인입니다. +- `korean100`의 장문 지시문은 처음에는 base 12/20, Worst5Q4 0/20으로 나왔지만, 모든 장문 응답이 max token에 걸렸습니다. + +## 6. 장문 512 재평가 + +장문 지시문은 기존 220/260 token 제한에서는 답변이 잘려 형식 평가가 왜곡될 가능성이 컸습니다. 그래서 long 계열 30개만 `max_tokens=512`로 재측정했습니다. + +| Model | N | Pass | Pass Rate | Avg Prefill t/s | Avg Decode t/s | Maxed | +|---|---:|---:|---:|---:|---:|---:| +| base | 30 | 30 | 100.00% | 152.36 | 31.55 | 0 | +| Worst5Q4 | 30 | 25 | 83.33% | 151.18 | 31.31 | 0 | + +이 재평가가 가장 중요합니다. 토큰 예산을 늘리면 base는 30/30으로 회복했고, Worst5Q4도 25/30까지 회복했습니다. 따라서 Worst5Q4의 장문 문제는 대부분 토큰 예산 영향이지만, 예산을 충분히 줘도 base 대비 약 5개 케이스에서 형식 안정성 손실이 남습니다. + +## 7. 속도 판단 + +속도는 매우 안정적입니다. + +- 한국어 held-out 100개 기준 decode: base 31.81 tok/s, Worst5Q4 31.63 tok/s +- control60 기준 decode: base 32.26 tok/s, Worst5Q4 31.95 tok/s +- 장문 512 기준 decode: base 31.55 tok/s, Worst5Q4 31.31 tok/s + +Worst5Q4는 Q4 layer 5개를 포함하지만 decode 속도 손실은 약 0.5~1.0% 수준입니다. Prefill도 평균적으로 큰 차이는 없습니다. + +## 8. Expert-Level Sidecar GGUF/Runtime 설계 + +현재 GGUF/runtime은 layer tensor 전체 qtype만 표현합니다. 즉 다음 세 tensor가 layer 단위로 통째로 `q2_k/iq2_xxs` 또는 `q4_k`가 됩니다. + +```text +blk.N.ffn_gate_exps.weight +blk.N.ffn_down_exps.weight +blk.N.ffn_up_exps.weight +``` + +Mixed32처럼 layer 내부 특정 expert만 Q4로 올리려면 별도 sidecar 표현이 필요합니다. + +### 8.1 GGUF tensor 제안 + +각 layer별로 선택된 expert만 담는 sidecar tensor를 추가합니다. + +```text +blk.N.ffn_gate_exps.bitlift_q4.weight +blk.N.ffn_up_exps.bitlift_q4.weight +blk.N.ffn_down_exps.bitlift_q4.weight +blk.N.ffn_exps.bitlift_q4.ids +``` + +`ids`는 해당 sidecar tensor의 expert 순서를 나타내는 `i32` 배열입니다. 예를 들어 `ids=[37, 184, ...]`이면 sidecar weight tensor의 expert slot 0은 원래 expert 37을 의미합니다. + +### 8.2 Metadata 제안 + +```text +quantize.bitlift.version = 1 +quantize.bitlift.mode = expert_sidecar +quantize.bitlift.base_qtype = q2_k/iq2_xxs +quantize.bitlift.sidecar_qtype = q4_k +quantize.bitlift.manifest_sha256 = ... +``` + +구버전 런타임은 알 수 없는 tensor를 무시하므로, 기존 모델 호환성을 깨지 않는 방향으로 넣을 수 있습니다. 단, 파일 크기는 sidecar만큼 증가합니다. + +### 8.3 Runtime routing 흐름 + +1. Router가 token별 top-k expert id를 선택합니다. +2. Runtime은 layer별 `expert_id -> sidecar_slot` lookup table을 확인합니다. +3. 선택 expert가 sidecar에 있으면 Q4_K sidecar slice를 사용합니다. +4. 없으면 기존 base low-bit expert tensor slice를 사용합니다. +5. token별 routed output은 기존 router weight를 곱해 누산합니다. + +### 8.4 Metal kernel 변경 포인트 + +- Decode path: selected expert마다 base/sidecar를 분기해 matvec을 호출해야 합니다. +- Prefill batch path: active expert set을 base group과 sidecar group으로 나누고, 결과를 같은 output buffer에 누산해야 합니다. +- 누산 순서가 바뀌면 미세한 수치 차이가 생길 수 있으므로, 가능하면 selected expert 순서를 유지하고 backend별 재현성 테스트를 넣어야 합니다. +- `ids` 중복, 범위 초과, sidecar tensor shape 불일치, qtype 불일치에 대한 로드 시점 검증이 필요합니다. + +### 8.5 Quantizer 변경 포인트 + +현재 quantizer는 tensor 전체를 생성합니다. sidecar 방식은 manifest를 입력받아 expert별 원본 HF slice만 Q4_K로 다시 양자화해야 합니다. + +필요한 옵션 예시는 다음과 같습니다. + +```text +--bitlift-manifest bitlift_mixed_top32_skip_existing4.json +--bitlift-sidecar-qtype q4_k +--bitlift-base-template MODEL.gguf +``` + +결과적으로 full layer Q4보다 용량 효율이 훨씬 좋고, `mixed_top32` 같은 trace 기반 후보를 실제로 반영할 수 있습니다. + +## 9. KR-Mixed32 실제 생성 판단 + +이번 턴에서는 `KR-Mixed32`를 실제 GGUF로 생성하지 않았습니다. 이유는 기술적/운영상 제약이 명확합니다. + +1. 현재 runtime은 expert 단위 qtype 선택을 지원하지 않습니다. +2. 현재 GGUF는 layer tensor 단위 qtype만 지원합니다. +3. 원본 `deepseek-ai/DeepSeek-V4-Flash` safetensors는 디스크 정리 과정에서 제거했습니다. +4. 외장 디스크의 `JANGTQ-K` safetensors는 `tq_packed/tq_norms/tq_bits` 구조라 현재 quantizer의 원본 HF 입력으로 사용할 수 없습니다. +5. 로컬 free 182GiB만으로는 원본 HF 약 149GiB와 새 GGUF 약 95~100GiB를 동시에 보관하기 어렵습니다. + +따라서 사용자가 말한 “Mixed 말고 지금처럼”을 반영하면, 다음 실제 생성 후보는 expert-level Mixed32가 아니라 layer 단위 Q4 확장 모델입니다. + +## 10. Layer-Q4 대체 생성안 + +현 구조에서 만들 수 있는 다음 모델은 예를 들어 다음 형태입니다. + +```text +DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-HotLate5Q4-chat-v2-imatrix.gguf +``` + +후보 layer는 stable core가 몰린 late routing layer입니다. + +```text +L37, L38, L40, L41, L42 +``` + +다만 이 모델은 기존 Worst5Q4의 `L23/L25/L28/L34/L36`에 추가로 hot late 5개 layer를 full Q4로 올리는 방식이라, 파일 크기가 대략 97~99GiB까지 늘 가능성이 있습니다. Worst5Q4가 장문 512에서 이미 base 대비 5개 케이스 손실을 보인 점을 고려하면, 무작정 layer를 더 올리기 전에 장문 안정성을 다시 검증해야 합니다. + +실제 생성에 필요한 선행 조건은 다음과 같습니다. + +```text +1. 원본 HF safetensors 재다운로드 +2. peak disk 확보: 원본 HF 약 149GiB + 출력 GGUF 약 98GiB + template GGUF +3. base GGUF 또는 기타 대형 파일 삭제/외장 이동에 대한 명시적 결정 +4. deepseek4-quantize layer override로 full layer Q4 생성 +5. 생성 후 동일 평가 재실행 +``` + +예상 명령 골격은 다음과 같습니다. + +```sh +gguf-tools/deepseek4-quantize --hf /path/to/deepseek-ai/DeepSeek-V4-Flash --template gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf --out gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-HotLate5Q4-chat-v2-imatrix.gguf --imatrix /path/to/DeepSeek-V4-Flash-chat-v2-routed-moe-ds4.dat --tensor-type blk.37.ffn_gate_exps.weight=q4_k --tensor-type blk.37.ffn_down_exps.weight=q4_k --tensor-type blk.37.ffn_up_exps.weight=q4_k --tensor-type blk.38.ffn_gate_exps.weight=q4_k --tensor-type blk.38.ffn_down_exps.weight=q4_k --tensor-type blk.38.ffn_up_exps.weight=q4_k --tensor-type blk.40.ffn_gate_exps.weight=q4_k --tensor-type blk.40.ffn_down_exps.weight=q4_k --tensor-type blk.40.ffn_up_exps.weight=q4_k --tensor-type blk.41.ffn_gate_exps.weight=q4_k --tensor-type blk.41.ffn_down_exps.weight=q4_k --tensor-type blk.41.ffn_up_exps.weight=q4_k --tensor-type blk.42.ffn_gate_exps.weight=q4_k --tensor-type blk.42.ffn_down_exps.weight=q4_k --tensor-type blk.42.ffn_up_exps.weight=q4_k --threads 8 +``` + +## 11. 한계점 + +현재 결과의 한계는 분명합니다. + +- 평가 스코어는 휴리스틱 기반입니다. 실제 한국어 품질을 완전히 대표하지 않습니다. +- exact-copy는 지나치게 엄격하며, 특히 자모 문자열은 base도 실패하므로 Worst5Q4만의 문제라고 보기 어렵습니다. +- 장문 평가는 token budget에 민감합니다. 220/260에서는 비교가 왜곡되고, 512에서는 훨씬 정상화됩니다. +- held-out 100개는 직접 구성한 synthetic set입니다. KMMLU, HAE-RAE, 실제 사용자 로그 기반 평가는 아직 아닙니다. +- routing trace는 “자주 쓰인 expert”를 찾은 것이지, “한국어에만 특화된 expert”를 분리한 것은 아닙니다. +- Mixed32 manifest는 후보 목록일 뿐이며, 현재 GGUF/runtime이 expert 단위 sidecar를 실행하지 못합니다. +- 새 full GGUF 생성은 disk peak와 원본 HF source availability가 병목입니다. + +## 12. 결론 + +Worst5Q4는 속도 손실이 거의 없고, 한국어 단문/요약/보안 설명/exact-copy/control 다국어에서는 안정적입니다. 문제는 장문 형식 안정성인데, token budget을 512로 올리면 대부분 회복되지만 base 대비 약간의 손실은 남습니다. + +따라서 지금 바로 더 많은 layer를 full Q4로 올리는 것은 조심해야 합니다. 다음 우선순위는 `KR-HotLate5Q4` 생성보다, expert-level sidecar runtime을 구현해 `mixed_top32`를 실제로 표현하는 것입니다. 다만 사용자가 “Mixed 말고 지금처럼”을 계속 원한다면, 다음 실험은 `HotLate5Q4`를 만들되 base GGUF 삭제 또는 원본 HF 재다운로드 위치를 먼저 결정해야 합니다. diff --git a/reports/ds4_followup_kmmlu300_thinkmax30_sidecar_20260519.md b/reports/ds4_followup_kmmlu300_thinkmax30_sidecar_20260519.md new file mode 100644 index 00000000..4bb40d67 --- /dev/null +++ b/reports/ds4_followup_kmmlu300_thinkmax30_sidecar_20260519.md @@ -0,0 +1,164 @@ +# DS4 KR Follow-up: KMMLU300, Think MAX30, Long-v2, Sidecar Runtime + +작성일: 2026-05-19 KST + +## 1. 결론 + +이번 재부팅 이후 `/tmp` 산출물이 사라져서 평가를 프로젝트 내부 `runs/20260519_followup/`로 옮기고 다시 완료했습니다. 결과는 꽤 분명합니다. + +- 일반 chat/nothink 기본 후보는 여전히 `base` 또는 `LateStable5Q4`입니다. +- KMMLU 300에서는 `LateStable5Q4`가 214/300으로 1등이지만, `Worst5Q4` 213/300, `Layer10Q4` 212/300, `base` 209/300이라 차이는 작습니다. +- Think MAX 30에서는 `Layer10Q4`가 17/30으로 `base`와 `LateStable5Q4`의 10/30을 크게 앞섭니다. +- 장문 지시문 v2 nothink에서는 `base`가 42/60으로 가장 안정적이고, `LateStable5Q4` 39/60, `Layer10Q4` 27/60입니다. +- 속도는 모든 축에서 decode 약 31 tok/s 전후로 유지되어, 현재 layer 단위 Q4 후보들의 속도 퇴화는 실사용 판단을 뒤집을 정도가 아닙니다. + +따라서 운영 정책은 다음처럼 가져가는 게 맞습니다. + +```text +일반 chat/nothink: base 우선, KMMLU/일반 지식이 더 중요하면 LateStable5Q4도 후보 +Think MAX 한국어: Layer10Q4 유지 +Mixed32: 지금 당장 기본 후보로 올리지 말고 sidecar 런타임 완성 뒤 재평가 +``` + +## 2. 산출물 위치 + +| 항목 | 경로 | +|---|---| +| 전체 run 디렉터리 | `/Users/kch3dri4n/llm_provide/ds4/runs/20260519_followup` | +| KMMLU 300 | `/Users/kch3dri4n/llm_provide/ds4/runs/20260519_followup/kmmlu300` | +| Think MAX 30 | `/Users/kch3dri4n/llm_provide/ds4/runs/20260519_followup/thinkmax30` | +| 장문 지시문 v2 | `/Users/kch3dri4n/llm_provide/ds4/runs/20260519_followup/longv2` | +| sidecar plan | `/Users/kch3dri4n/llm_provide/ds4/runs/20260519_followup/sidecar_plan` | +| 본 보고서 | `/Users/kch3dri4n/llm_provide/ds4/reports/ds4_followup_kmmlu300_thinkmax30_sidecar_20260519.md` | + +## 3. 모델 파일 상태 + +| 모델 | 경로 | 크기 | +|---|---|---:| +| base | `/Users/kch3dri4n/llm_provide/ds4/ds4flash.gguf` | 80.76 GiB | +| worst5q4 | `/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf` | 89.20 GiB | +| latestable5q4 | `/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-LateStable5Q4-chat-v2.gguf` | 89.20 GiB | +| layer10q4 | `/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Layer10Q4-chat-v2.gguf` | 97.64 GiB | + +## 4. KMMLU 300 결과 + +| model | correct | n | accuracy | invalid | prefill tok/s | decode tok/s | +|---|---:|---:|---:|---:|---:|---:| +| base | 209 | 300 | 69.67% | 0 | 153.50 | 31.46 | +| worst5q4 | 213 | 300 | 71.00% | 0 | 150.80 | 31.32 | +| latestable5q4 | 214 | 300 | 71.33% | 0 | 152.87 | 31.23 | +| layer10q4 | 212 | 300 | 70.67% | 1 | 150.82 | 31.19 | + +해석: + +- `LateStable5Q4`가 base 대비 +5문항입니다. +- `Layer10Q4`도 base 대비 +3문항이라 KMMLU 자체가 나빠졌다고 보기는 어렵습니다. +- 다만 `Layer10Q4`는 invalid prediction 1개가 있어 객관식 format 안정성은 `base/LateStable5Q4`보다 약간 낮습니다. +- KMMLU 300만 보면 `LateStable5Q4`가 가장 깔끔합니다. + +## 5. Think MAX 30 결과 + +| model | pass | n | pass_rate | prefill tok/s | decode tok/s | avg generated tokens | korean subset | long subset | exact subset | control subset | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| base | 10 | 30 | 33.33% | 150.12 | 31.71 | 304.57 | 3/10 | 3/8 | 0/6 | 4/6 | +| latestable5q4 | 10 | 30 | 33.33% | 149.71 | 31.32 | 295.37 | 3/10 | 3/8 | 0/6 | 4/6 | +| layer10q4 | 17 | 30 | 56.67% | 150.03 | 31.04 | 319.70 | 8/10 | 5/8 | 0/6 | 4/6 | + +해석: + +- `Layer10Q4`는 Think MAX에서 base/latestable 대비 +7개 pass입니다. +- 한국어 subset만 보면 `Layer10Q4` 8/10, base 3/10, latestable 3/10입니다. 이건 우연이라기보다 Layer10Q4가 thinking/high 계열에 더 맞는 신호입니다. +- long subset도 `Layer10Q4` 5/8로 base/latestable 3/8보다 좋습니다. +- exact subset은 세 모델 모두 0/6입니다. Think MAX에서 exact-copy는 아직 별도 프롬프트/디코딩 전략 문제가 큽니다. +- decode 속도는 `Layer10Q4`가 31.04 tok/s로 약간 낮지만, 품질 차이를 감안하면 Think MAX 후보로 유지할 만합니다. + +## 6. 장문 지시문 v2 결과 + +| model | pass | n | pass_rate | avg_score | prefill tok/s | decode tok/s | avg generated tokens | +|---|---:|---:|---:|---:|---:|---:|---:| +| base | 42 | 60 | 70.00% | 0.856 | 168.70 | 31.38 | 353.30 | +| latestable5q4 | 39 | 60 | 65.00% | 0.835 | 165.54 | 31.35 | 357.25 | +| layer10q4 | 27 | 60 | 45.00% | 0.816 | 164.53 | 31.18 | 364.33 | + +세부 kind별 pass: + +| model | basic_plan | risk_plan | term_explain | validation_plan | +|---|---:|---:|---:|---:| +| base | 15/15 | 6/15 | 15/15 | 6/15 | +| latestable5q4 | 15/15 | 6/15 | 12/15 | 6/15 | +| layer10q4 | 12/15 | 0/15 | 9/15 | 6/15 | + +해석: + +- 일반 nothink 장문은 `base`가 가장 안정적입니다. +- `LateStable5Q4`는 base보다 약간 낮지만 KMMLU가 좋아서 일반 지식형 chat 후보로는 남길 수 있습니다. +- `Layer10Q4`는 risk_plan 0/15로 명확한 약점이 있습니다. Think MAX에서는 강하지만 일반 nothink 장문 기본값으로 올리면 안 됩니다. +- 장문 v2는 기존 장문 평가보다 까다롭습니다. 제목 길이, bullet 개수, 단계 수, 표 금지, 위험/완화, 검증 필요 항목, 전문용어 괄호 설명을 개별 criterion으로 봅니다. + +## 7. Expert-level Sidecar GGUF/runtime 진행 상황 + +이번에 runtime 쪽에는 sidecar-aware loader/validation 기반을 붙였습니다. + +추가한 런타임 계약: + +```text +blk.N.ffn_gate_exps.bitlift_q4.weight +blk.N.ffn_up_exps.bitlift_q4.weight +blk.N.ffn_down_exps.bitlift_q4.weight +blk.N.ffn_exps.bitlift_q4.ids +``` + +동작: + +- layer별 optional sidecar tensor 4종을 탐지합니다. +- 4종 중 일부만 있으면 로드 시점에서 실패시킵니다. +- `ids`는 1D `i32`, expert id 범위 0..255, 중복 없음으로 검증합니다. +- gate/up/down sidecar tensor는 `Q4_K`이고 각각 `[4096, 2048, sidecar_count]`, `[4096, 2048, sidecar_count]`, `[2048, 4096, sidecar_count]` 형태로 검증합니다. +- 런타임에 `expert_id -> sidecar_slot` lookup을 layer별로 구축합니다. +- 기존 GGUF에서는 `./ds4 --inspect -m ds4flash.gguf` 기준 `bitlift sidecar: none`으로 정상 로드됨을 확인했습니다. + +생성된 sidecar plan: + +| plan | layers | expert slots | tensors to add | manifest sha256 | +|---|---:|---:|---:|---| +| mixed_top32 | 38 | 1216 | 152 | `f62e149254c11cf5ffeb3399eb560f1ec5e327e32c24715a827d9e125ee399b1` | +| think_priority_top32 | 38 | 1216 | 152 | `a1c86014ef8b35ed8bf3952f10f0d7a001887f5c661e41dba331e5cde194972d` | + +중요한 한계: + +- 이번 패치는 sidecar tensor를 안전하게 인식하고 검증하는 loader/runtime 기반입니다. +- 실제 Metal fused MoE dispatch가 sidecar Q4 slice를 선택해 계산하는 단계는 아직 남아 있습니다. +- 따라서 아직 `Mixed32 sidecar GGUF`를 넣으면 품질 평가에 쓰면 안 됩니다. 다음 패치는 Metal decode/prefill routed MoE에서 selected expert별 base/sidecar dispatch를 구현해야 합니다. + +## 8. 성능 판단 + +- KMMLU decode: 31.19~31.46 tok/s +- Think MAX decode: 31.04~31.71 tok/s +- 장문 v2 decode: 31.18~31.38 tok/s + +Q4 layer 변형 간 decode 차이는 작습니다. 품질 차이가 모델 선택을 결정하고, 속도는 현재 후보군에서 보조 지표입니다. + +## 9. 최종 의사결정 + +```text +일반 chat/nothink 기본값: base +일반 지식/KMMLU 중시 후보: LateStable5Q4 +Think MAX 한국어 후보: Layer10Q4 +장문 nothink 기본값: Layer10Q4 제외 +Mixed32: sidecar dispatch 구현 전까지 보류 +``` + +## 10. 다음 작업 + +1. Metal decode MoE에서 selected expert별 sidecar slot 분기 구현 +2. Metal prefill batch MoE에서 active expert를 base/sidecar 그룹으로 나누는 dispatch 구현 +3. sidecar GGUF writer에서 plan JSON을 읽어 Q4_K sidecar tensor와 i32 ids tensor 생성 +4. `think_priority_top32` sidecar를 먼저 생성해 Think MAX 30을 재평가 +5. `mixed_top32` sidecar는 KMMLU/장문 v2까지 포함해 기본값 후보인지 별도 검증 + +## 11. 체크섬/재현성 메모 + +- KMMLU sample: `n=300`, `seed=20260519` +- Think MAX: `ctx=393216`, `preset=expanded30`, `seed=7` +- Long v2: `n=60`, `ctx=4096`, greedy/nothink +- 모든 결과는 재시작 후에도 이어서 쓸 수 있게 raw JSONL 기반 resume 형태로 저장했습니다. diff --git a/reports/ds4_hf_fp4_l8_l12_sidecar_20260520.md b/reports/ds4_hf_fp4_l8_l12_sidecar_20260520.md new file mode 100644 index 00000000..09e8f15b --- /dev/null +++ b/reports/ds4_hf_fp4_l8_l12_sidecar_20260520.md @@ -0,0 +1,492 @@ +# DS4 한국어 Bit-Lift Source Sidecar 실험 보고서 + +작성일: 2026-05-20 +작업 디렉터리: `/Users/kch3dri4n/llm_provide/ds4` +대상 모델: DeepSeek-V4-Flash JANGTQ-K / DS4 GGUF runtime +이번 실험명: `ThinkTop32 L10 / L8-L12 HF-FP4 source sidecar` + +## 1. 결론 + +이번 실험의 핵심 결론은 명확합니다. + +**원본 shard 기반 sidecar 파이프라인은 성공했습니다.** +L10 단독 sidecar와 L8-L12 sidecar 모두 GGUF로 생성했고, runtime에서 sidecar expert route가 실제로 활성화되며 계산 경로에 들어가는 것을 trace로 확인했습니다. + +하지만 **품질 기준으로는 아직 실전 후보가 아닙니다.** +현재까지의 최강 후보는 여전히 `Layer10Q4` 전체 GGUF입니다. 일반 chat/nothink에서는 base가 가장 안정적이고, Think MAX 및 KMMLU에서는 `Layer10Q4`가 가장 강합니다. + +이번 source sidecar 중에서는 `L8-L12 HF-FP4`가 가장 나았습니다. 다만 KMMLU 300에서 base보다도 낮아졌고, Think MAX 30에서도 `Layer10Q4`를 넘지 못했습니다. 따라서 `L8-L12 HF-FP4`는 실전 배포 후보라기보다 “source-based sidecar가 동작하며 일부 장문 형식 준수는 보존된다”는 검증 결과로 보는 것이 맞습니다. + +최종 운영 판단은 다음과 같습니다. + +- 일반 chat / nothink: `base` 유지 +- Think MAX 한국어 실험: `Layer10Q4` 유지 +- source sidecar 후보: `L8-L12 HF-FP4`는 보관, 실전 주력 아님 +- L10 단독 source sidecar: 작동 검증용, 품질 후보에서는 제외 +- 다음 승부: top32만 올리는 방식이 아니라 `Layer10 전체 expert` 또는 더 넓은 expert coverage를 source-sidecar로 검증 + +## 2. 생성된 산출물 + +### 2.1 L10 source sidecar + +파일: + +```text +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop32-L10-HF-FP4.sidecar.gguf +``` + +요약: + +```text +크기: 432M +layer_count: 1 +layers: L10 +expert_slot_count: 32 +tensor_count: 4 +source shard: model-00012-of-00046.safetensors +source format: packed_fp4_i8_plus_f8_e8m0_scales +qtype: Q4_K sidecar payload +sha256: 2032fb69e0f297290fcaa30c1b3dda8f96bfeb509a1a0c31f6005eac160600cf +``` + +빌드 summary: + +```text +runs/20260520_hf_fp4_l8_l12/thinktop32_l10_hf_fp4.summary.json +``` + +### 2.2 L8-L12 source sidecar + +파일: + +```text +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop32-L8-L12-HF-FP4.sidecar.gguf +``` + +요약: + +```text +크기: 2.1G +layer_count: 5 +layers: L8, L9, L10, L11, L12 +expert_slot_count: 160 +tensor_count: 20 +source shards: model-00010-of-00046.safetensors ... model-00014-of-00046.safetensors +source format: packed_fp4_i8_plus_f8_e8m0_scales +qtype: Q4_K sidecar payload +sha256: ed87c85c9fd655c1f581b4f2a14f4b74e633f668adbdba6c8121551fe023f38c +``` + +빌드 summary: + +```text +runs/20260520_hf_fp4_l8_l12/thinktop32_l8_l12_hf_fp4.summary.json +``` + +### 2.3 사용한 source shards + +다운로드 위치: + +```text +/Volumes/Back_UP/hf-cache-offload/deepseek-ai/DeepSeek-V4-Flash-late5 +``` + +Layer mapping: + +```text +L8 -> model-00010-of-00046.safetensors +L9 -> model-00011-of-00046.safetensors +L10 -> model-00012-of-00046.safetensors +L11 -> model-00013-of-00046.safetensors +L12 -> model-00014-of-00046.safetensors +``` + +주의: 여기서 “source-based”는 기존 GGUF에서 다시 뽑은 값이 아니라 Hugging Face source shard에서 직접 sidecar를 만들었다는 뜻입니다. 다만 해당 shard 자체가 full FP16 원본이 아니라 `packed_fp4_i8_plus_f8_e8m0_scales` 형식입니다. 즉 “고정밀 원본 weight”라기보다는 “상위 source artifact 기반”입니다. + +## 3. Runtime activation 검증 + +### 3.1 L10 source sidecar + +Trace 파일: + +```text +runs/20260520_hf_fp4_l8_l12/l10_hf_fp4_thinkmax_trace_summary.json +``` + +Think MAX smoke prompt에서 L10 sidecar가 실제 routing 계산에 들어갔습니다. + +```text +trace rows: 219 +L10 base routes: 519 +L10 sidecar routes: 795 +L10 sidecar rate: 60.50% +``` + +상위 sidecar slots: + +```text +L10 slot 1: 198 hits +L10 slot 0: 111 hits +L10 slot 5: 44 hits +L10 slot 2: 42 hits +L10 slot 6: 41 hits +``` + +판정: sidecar가 장식으로 붙은 것이 아니라 실제 dispatch에서 선택됩니다. + +### 3.2 L8-L12 source sidecar + +Trace 파일: + +```text +runs/20260520_hf_fp4_l8_l12/l8_l12_hf_fp4_thinkmax_trace_summary.json +``` + +Think MAX smoke prompt에서 다섯 layer 모두 sidecar route가 활성화되었습니다. + +```text +L8 sidecar rate: 66.21% +L9 sidecar rate: 50.00% +L10 sidecar rate: 55.10% +L11 sidecar rate: 51.75% +L12 sidecar rate: 65.98% +``` + +상위 sidecar slots: + +```text +L10 slot 1: 199 hits +L8 slot 0: 157 hits +L12 slot 1: 150 hits +L8 slot 3: 132 hits +L11 slot 0: 122 hits +``` + +판정: L8-L12 sidecar도 runtime에서 안정적으로 활성화됩니다. 다만 활성화율이 높다는 사실이 품질 개선을 보장하지는 않았습니다. + +## 4. Think MAX 30 평가 + +평가 경로: + +```text +runs/20260520_hf_fp4_l8_l12/thinkmax30_base_layer10_l10src_l8l12src +``` + +요약: + +| 모델 | pass | pass rate | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:| +| base | 10/30 | 33.33% | 148.99 | 31.65 | +| Layer10Q4 | 17/30 | 56.67% | 148.33 | 31.19 | +| L10 HF-FP4 sidecar | 9/30 | 30.00% | 148.88 | 31.33 | +| L8-L12 HF-FP4 sidecar | 14/30 | 46.67% | 146.46 | 30.69 | + +세부: + +```text +base Korean suite: 3/10 +Layer10Q4 Korean suite: 8/10 +L10 HF-FP4 Korean suite: 2/10 +L8-L12 HF-FP4 Korean suite: 5/10 +``` + +해석: + +- `Layer10Q4`가 Think MAX에서는 확실한 1등입니다. +- `L8-L12 HF-FP4`는 base보다는 낫지만 `Layer10Q4`와 격차가 큽니다. +- `L10 HF-FP4`는 활성화율 60%에도 불구하고 품질이 base보다 낮았습니다. +- 속도는 대체로 양호합니다. L8-L12 source sidecar도 decode 기준 약 3% 이내 하락입니다. + +## 5. KMMLU 100 평가 + +평가 경로: + +```text +runs/20260520_hf_fp4_l8_l12/kmmlu100_base_layer10_l10src_l8l12src +``` + +요약: + +| 모델 | correct | accuracy | invalid | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:|---:| +| base | 70/100 | 70.00% | 0 | 153.12 | 31.23 | +| Layer10Q4 | 79/100 | 79.00% | 0 | 149.48 | 31.13 | +| L10 HF-FP4 sidecar | 67/100 | 67.00% | 0 | 151.55 | 31.19 | +| L8-L12 HF-FP4 sidecar | 66/100 | 66.00% | 1 | 148.72 | 30.61 | + +해석: + +- `Layer10Q4`가 100개 샘플에서 크게 우세합니다. +- source sidecar 둘은 base보다 낮습니다. +- L8-L12는 Think MAX에서는 base를 넘었지만 KMMLU에서는 base보다 낮아졌습니다. + +## 6. KMMLU 300 평가 + +평가 경로: + +```text +runs/20260520_hf_fp4_l8_l12/kmmlu300_base_layer10_l10src_l8l12src +``` + +요약: + +| 모델 | correct | accuracy | invalid | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:|---:| +| base | 197/300 | 65.67% | 0 | 156.28 | 30.90 | +| Layer10Q4 | 210/300 | 70.00% | 1 | 153.42 | 30.63 | +| L10 HF-FP4 sidecar | 197/300 | 65.67% | 2 | 155.54 | 30.67 | +| L8-L12 HF-FP4 sidecar | 182/300 | 60.67% | 2 | 152.62 | 30.05 | + +해석: + +- 표본을 300개로 늘려도 `Layer10Q4` 우세가 유지됩니다. +- `L10 HF-FP4 sidecar`는 base와 같은 correct 수지만 invalid가 2개 있어 실제 안정성은 base보다 낮습니다. +- `L8-L12 HF-FP4 sidecar`는 KMMLU에서 명확히 퇴화했습니다. +- 이 결과 때문에 L8-L12 source sidecar를 “한국어 지식형 성능 개선 후보”로 채택하면 안 됩니다. + +## 7. 한국어 held-out / control / exact-long 평가 + +평가 경로: + +```text +runs/20260520_hf_fp4_l8_l12/project_eval_base_layer10_l10src_l8l12src +``` + +### 7.1 한국어 held-out 100 + +| 모델 | pass | pass rate | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:| +| base | 88/100 | 88.00% | 122.99 | 31.88 | +| Layer10Q4 | 84/100 | 84.00% | 121.24 | 31.66 | +| L10 HF-FP4 sidecar | 85/100 | 85.00% | 122.79 | 31.81 | +| L8-L12 HF-FP4 sidecar | 83/100 | 83.00% | 120.18 | 31.04 | + +해석: + +- 일반 nothink 한국어 작성/요약/기술/복사/계획에서는 base가 가장 안정적입니다. +- L10 source sidecar는 Layer10Q4보다 1점 높지만 base보다 낮습니다. +- L8-L12 source sidecar는 네 모델 중 가장 낮습니다. + +### 7.2 영어/중국어/control 60 + +| 모델 | pass | pass rate | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:| +| base | 60/60 | 100.00% | 59.21 | 32.28 | +| Layer10Q4 | 60/60 | 100.00% | 58.37 | 31.94 | +| L10 HF-FP4 sidecar | 60/60 | 100.00% | 59.07 | 32.07 | +| L8-L12 HF-FP4 sidecar | 60/60 | 100.00% | 57.71 | 31.13 | + +해석: + +- 영어/중국어/control 퇴화는 이 샘플에서는 관측되지 않았습니다. +- L8-L12 source sidecar의 decode 속도는 base 대비 약 3.6% 낮습니다. + +### 7.3 exact-copy + extra long 30 + +| 모델 | pass | pass rate | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:| +| base | 10/30 | 33.33% | 137.33 | 31.84 | +| Layer10Q4 | 10/30 | 33.33% | 133.52 | 31.41 | +| L10 HF-FP4 sidecar | 9/30 | 30.00% | 136.25 | 31.54 | +| L8-L12 HF-FP4 sidecar | 8/30 | 26.67% | 134.35 | 30.67 | + +해석: + +- exact-copy는 전체적으로 약점입니다. +- source sidecar가 exact-copy를 개선하지 못했고 오히려 낮췄습니다. +- 한글 자모/공백/태그 복사 계열은 별도 보정 대상입니다. + +## 8. 장문 지시문 v2 평가 + +평가 경로: + +```text +runs/20260520_hf_fp4_l8_l12/longv2_base_layer10_l10src_l8l12src +``` + +요약: + +| 모델 | pass | pass rate | avg score | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:|---:| +| base | 42/60 | 70.00% | 0.8556 | 168.46 | 31.40 | +| Layer10Q4 | 27/60 | 45.00% | 0.8196 | 166.05 | 30.95 | +| L10 HF-FP4 sidecar | 38/60 | 63.33% | 0.8455 | 167.67 | 31.11 | +| L8-L12 HF-FP4 sidecar | 41/60 | 68.33% | 0.8426 | 164.38 | 30.45 | + +해석: + +- 장문 형식 준수에서는 base가 가장 강합니다. +- `L8-L12 HF-FP4`는 41/60으로 base와 1점 차이까지 접근했습니다. +- `Layer10Q4`는 Think MAX/KMMLU에서는 강하지만 장문 형식 준수에서는 크게 낮아집니다. +- 따라서 Layer10Q4는 “thinking/high + 지식형 한국어” 후보이지, 모든 한국어 작업의 전역 기본값은 아닙니다. + +## 9. 속도 평가 + +전반적인 속도는 실사용 가능한 범위입니다. + +### Think MAX 30 기준 + +```text +base decode: 31.65 t/s +Layer10Q4 decode: 31.19 t/s +L10 source sidecar decode: 31.33 t/s +L8-L12 source sidecar decode: 30.69 t/s +``` + +### KMMLU 300 기준 + +```text +base decode: 30.90 t/s +Layer10Q4 decode: 30.63 t/s +L10 source sidecar decode: 30.67 t/s +L8-L12 source sidecar decode: 30.05 t/s +``` + +### 장문 v2 기준 + +```text +base decode: 31.40 t/s +Layer10Q4 decode: 30.95 t/s +L10 source sidecar decode: 31.11 t/s +L8-L12 source sidecar decode: 30.45 t/s +``` + +판정: + +- sidecar runtime overhead는 크지 않습니다. +- L8-L12 sidecar도 대략 2-4% 수준의 decode 하락에 머뭅니다. +- 병목은 속도가 아니라 품질입니다. + +## 10. 왜 활성화되는데 품질은 안 오르는가 + +이번 실험에서 가장 중요한 학습은 이것입니다. + +```text +high route hit rate != quality gain +``` + +L10 source sidecar는 Think MAX smoke에서 sidecar route rate가 60.50%였습니다. L8-L12 source sidecar도 L8/L12에서 66% 수준의 sidecar route rate를 보였습니다. 그런데 품질은 `Layer10Q4`를 넘지 못했습니다. + +가능한 원인은 다음과 같습니다. + +1. Top32 expert만으로는 Layer10Q4 효과를 재현하기 부족합니다. +2. Layer10Q4의 개선은 특정 hot expert 일부가 아니라 layer 전체 expert 분포의 정밀도 변화에서 나온 것일 수 있습니다. +3. HF source shard가 full FP16이 아니라 packed FP4이므로 “source 기반”이라도 정보량이 제한됩니다. +4. sidecar Q4_K 변환이 기존 JANGTQ-K 본체의 양자화/스케일링 특성과 정확히 맞지 않을 수 있습니다. +5. Think MAX에서 일부 smoke output이 영어 meta reasoning으로 시작했습니다. 이는 routing 변경이 출력 스타일 안정성에도 영향을 줄 수 있음을 시사합니다. +6. KMMLU처럼 1-token multiple choice 답변에서는 작은 logit shift가 정답률을 크게 흔들 수 있습니다. + +## 11. 이번 실험의 한계 + +### 11.1 source shard는 full precision 원본이 아닙니다 + +사용한 HF shard는 `packed_fp4_i8_plus_f8_e8m0_scales`입니다. 기존 GGUF에서 재추출한 것보다는 낫지만, full FP16/BF16 원본 weight에서 Q4로 올린 실험은 아닙니다. + +### 11.2 expert coverage가 좁습니다 + +이번 source sidecar는 ThinkTop32 후보만 올렸습니다. + +```text +L10: 32 experts +L8-L12: 5 layers * 32 experts = 160 experts +``` + +Layer10Q4 전체 모델은 layer 전체 routed expert 정밀도를 바꿉니다. 따라서 효과가 특정 top32에만 있는지, 전체 layer의 분포 안정화에 있는지 아직 분해되지 않았습니다. + +### 11.3 평가 기준은 자동 휴리스틱입니다 + +한국어 held-out, exact-copy, long-v2는 자동 scoring rule입니다. 사람 평가와 완전히 같지는 않습니다. 다만 같은 프롬프트, 같은 러너, 같은 seed에서 모델 간 상대 비교를 하는 데는 충분히 유용합니다. + +### 11.4 Think MAX output 스타일 문제 + +source sidecar smoke에서 한국어 지시에도 영어 meta 문장이 출력 초반에 나타난 사례가 있었습니다. 이는 고정밀 후보가 “정답률”뿐 아니라 응답 스타일에도 영향을 준다는 신호입니다. + +### 11.5 단일 seed / 단일 샘플링 온도 + +대부분 `temp=0`, 고정 seed, 고정 prompt set입니다. 안정적인 비교에는 좋지만, 다양한 sampling 설정에서의 회복력은 아직 별도로 보지 않았습니다. + +## 12. 디스크 상태와 정리 + +현재 외장디스크: + +```text +/Volumes/Back_UP: 466Gi total, 450Gi used, 15Gi available +``` + +이번에 보존한 핵심 artifact: + +```text +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop32-L10-HF-FP4.sidecar.gguf +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop32-L8-L12-HF-FP4.sidecar.gguf +``` + +보존할 가치: + +- L10 source sidecar: 작은 smoke/regression test용 +- L8-L12 source sidecar: source sidecar 중 가장 의미 있는 비교 후보 +- L8-L12 source shards: 다음 full-layer 또는 top64/top128 sidecar 실험에 필요 + +삭제 후보: + +- L10 source sidecar는 품질 후보가 아니므로 공간이 급하면 삭제 가능 +- Late5 HF-FP4 sidecar는 proof-of-pipeline 성격이므로 보관 우선순위 낮음 + +## 13. 다음 단계 제안 + +이번 결과를 보고 다음 단계는 두 갈래 중 하나로 가야 합니다. + +### A. 실사용 기준 + +바로 쓸 모델 기준은 다음이 맞습니다. + +```text +nothink 일반 한국어: base +Think MAX / 한국어 지식형: Layer10Q4 +장문 형식 준수: base 우선, L8-L12 source는 연구 후보 +``` + +### B. 연구 기준 + +source-sidecar 방향을 계속 간다면 다음은 top32 확장이 아니라 coverage를 늘려야 합니다. + +1. `Layer10 full expert source sidecar` 생성 + L10의 256 routed experts 전체를 source shard에서 Q4 sidecar로 생성합니다. 예상 크기는 L10 top32의 8배 수준, 대략 3.4GiB 전후입니다. + +2. `Layer10 top64/top128 source sidecar` 생성 + top32가 너무 좁았을 가능성을 검증합니다. + +3. `L8-L12 top64`는 후순위 + KMMLU 300에서 L8-L12 top32가 크게 낮아졌으므로, 무작정 layer 폭을 넓히는 것보다 L10 coverage를 넓히는 쪽이 더 논리적입니다. + +4. source format 재검증 + 가능하다면 full precision 또는 더 높은 정밀도의 upstream shard가 있는지 확인해야 합니다. 현재 source는 packed FP4라서 “고정밀 원본” 효과를 완전히 검증하지 못했습니다. + +## 14. 최종 판정 + +이번 실험은 pipeline 관점에서는 성공, 모델 후보 관점에서는 부분 실패입니다. + +성공한 것: + +- L8-L12 source shards 확보 +- HF packed FP4 source에서 sidecar GGUF 생성 +- L10 및 L8-L12 sidecar runtime 로딩 +- sidecar expert route 활성화 trace 확인 +- Think MAX, KMMLU 100/300, 한국어 held-out, control, exact-copy, 장문 v2 평가 완료 +- 속도 overhead가 작다는 점 확인 + +실패 또는 보류: + +- source sidecar가 `Layer10Q4` 품질을 넘지 못함 +- L8-L12 source sidecar가 KMMLU 300에서 base보다 낮음 +- L10 source sidecar는 Think MAX와 KMMLU에서 약함 +- exact-copy 개선 없음 + +따라서 지금 당장 채택할 운영 전략은 다음입니다. + +```text +base는 일반 nothink 기본값으로 유지합니다. +Layer10Q4는 Think MAX 한국어 실험 후보로 유지합니다. +L8-L12 source sidecar는 연구 artifact로 보관하되 실전 기본값으로 쓰지 않습니다. +다음 실험은 Layer10 full/top64/top128 source sidecar로 진행합니다. +``` + diff --git a/reports/ds4_hf_fp4_late5_sidecar_20260520.md b/reports/ds4_hf_fp4_late5_sidecar_20260520.md new file mode 100644 index 00000000..96e4c048 --- /dev/null +++ b/reports/ds4_hf_fp4_late5_sidecar_20260520.md @@ -0,0 +1,325 @@ +# DS4 HF-FP4 Late5 Sidecar 결과 보고서 + +작성일: 2026-05-20 +작업 디렉터리: `/Users/kch3dri4n/llm_provide/ds4` + +## 1. 최종 결론 + +고정밀 원본 weight 기반 sidecar 경로는 성공했습니다. 기존 `base GGUF -> Q4 sidecar` 재포장 방식은 품질이 크게 무너졌지만, 이번에는 로컬에 남아 있던 HF 원본 계열 shard의 `packed FP4 + F8_E8M0 scale`에서 직접 Q4_K sidecar를 생성했고, Metal 런타임에서 실제 routed expert dispatch까지 정상 동작했습니다. + +다만 품질 결론은 보수적입니다. + +- 일반 chat / nothink 기본 모델로는 여전히 `base` 또는 기존 안정 후보를 유지하는 것이 맞습니다. +- Think MAX 실험 후보로는 `Layer10Q4`가 아직 1순위입니다. +- 새 `ThinkTop32-Late5-HF-FP4` sidecar는 base보다 Think MAX에서 개선을 보였지만, Layer10Q4를 넘지는 못했습니다. +- 이번 성과의 핵심은 "source-weight sidecar 생성 및 runtime 계산 경로가 된다"는 증명입니다. 다음 승부는 Late5가 아니라 Layer10 근방 또는 Think MAX trace 기반 early/mid layer 원본 shard를 확보해서 같은 방식으로 sidecar를 만드는 것입니다. + +## 2. 생성한 sidecar + +생성 파일: + +```text +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop32-Late5-HF-FP4.sidecar.gguf +``` + +로컬 링크: + +```text +/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Late5-HF-FP4.sidecar.gguf +``` + +크기와 체크섬: + +```text +file size: 2,264,926,752 bytes, about 2.1 GiB +sha256: 6a13f9cf493b2683f3ee4c5fce905732e042d3a3e9025d6e03d53121e87a9309 +``` + +구성: + +```text +source: HF packed FP4 I8 weight + F8_E8M0 scale +layers: L37, L38, L40, L41, L42 +expert slots: 160 +experts per layer: 32 +tensors: 20 +sidecar qtype: Q4_K +payload bytes: 2,264,924,800 +``` + +사용한 local source shards: + +```text +/Volumes/Back_UP/hf-cache-offload/deepseek-ai/DeepSeek-V4-Flash-late5/model-00039-of-00046.safetensors +/Volumes/Back_UP/hf-cache-offload/deepseek-ai/DeepSeek-V4-Flash-late5/model-00040-of-00046.safetensors +/Volumes/Back_UP/hf-cache-offload/deepseek-ai/DeepSeek-V4-Flash-late5/model-00042-of-00046.safetensors +/Volumes/Back_UP/hf-cache-offload/deepseek-ai/DeepSeek-V4-Flash-late5/model-00043-of-00046.safetensors +/Volumes/Back_UP/hf-cache-offload/deepseek-ai/DeepSeek-V4-Flash-late5/model-00044-of-00046.safetensors +``` + +주의: 이것은 BF16/FP16 원본이 아니라, 현재 로컬에 확보된 HF upstream 계열의 FP4+E8M0 source입니다. 그래도 기존 base GGUF의 routed expert Q2/IQ2 재포장보다 source 손실이 적은 입력입니다. + +## 3. 구현 내용 + +새 writer: + +```text +/Users/kch3dri4n/llm_provide/ds4/tools/write_bitlift_sidecar_from_hf_fp4.py +``` + +핵심 동작: + +- safetensors index와 shard header를 직접 읽습니다. +- HF tensor 이름 `layers.N.ffn.experts.E.w1/w2/w3.{weight,scale}`를 sidecar tensor로 매핑합니다. +- `w1 -> gate`, `w3 -> up`, `w2 -> down`으로 매핑합니다. +- packed FP4 nibble을 E8M0 scale로 dequant한 뒤 Q4_K로 재양자화합니다. +- compact sidecar GGUF tensor를 씁니다. + +추가한 quantizer 경로: + +```text +/Users/kch3dri4n/llm_provide/ds4/gguf-tools/quants.c +/Users/kch3dri4n/llm_provide/ds4/gguf-tools/quants.h +``` + +핵심 함수: + +```text +ds4q_quantize_fp4_e8m0_to_q4_k_chunk(...) +``` + +런타임 쪽 기존 sidecar loader/Metal dispatch는 새 sidecar를 정상 인식했습니다. + +## 4. 런타임 검증 + +inspect 결과: + +```text +bitlift sidecar: layers=5 expert_slots=160 tensor_triplets=15 qtype=q4_k +Metal sidecar mapped: about 2160.02 MiB +``` + +smoke sidecar도 따로 생성했습니다. + +```text +runs/20260520_hf_fp4_sidecar/smoke_layer37_2_from_hf_fp4.sidecar.gguf +size: about 27 MiB +layer: 37 +expert slots: 2 +``` + +생성 테스트: + +- `--nothink` 생성 정상 +- `--think-max --ctx 393216` 생성 정상 +- `DS4_BITLIFT_TRACE_HITS=1`에서 sidecar route hit 로그 정상 + +## 5. Expert 활성화 확인 + +### nothink trace + +파일: + +```text +runs/20260520_hf_fp4_sidecar/late5_hf_fp4_nothink_trace_summary.json +``` + +요약: + +| layer | rows/tokens | base routes | sidecar routes | sidecar rate | +|---:|---:|---:|---:|---:| +| 37 | 139 | 243 | 591 | 70.86% | +| 38 | 139 | 209 | 625 | 74.94% | +| 40 | 139 | 237 | 597 | 71.58% | +| 41 | 139 | 210 | 624 | 74.82% | +| 42 | 139 | 246 | 588 | 70.50% | + +### Think MAX trace + +파일: + +```text +runs/20260520_hf_fp4_sidecar/late5_hf_fp4_thinkmax_trace_summary.json +``` + +요약: + +| layer | rows/tokens | base routes | sidecar routes | sidecar rate | +|---:|---:|---:|---:|---:| +| 37 | 219 | 639 | 675 | 51.37% | +| 38 | 219 | 434 | 880 | 66.97% | +| 40 | 219 | 520 | 794 | 60.43% | +| 41 | 219 | 514 | 800 | 60.88% | +| 42 | 219 | 655 | 659 | 50.15% | + +해석: + +- sidecar expert는 실제로 꽤 자주 활성화됩니다. +- nothink에서는 late layer 후보가 매우 자주 잡힙니다. +- Think MAX에서는 활성률이 낮아지지만 여전히 절반 이상 경로에서 sidecar가 사용됩니다. +- 따라서 품질이 부족한 원인은 "sidecar가 안 쓰여서"가 아니라 "Late5 top32가 최적 품질 위치가 아니어서"로 보는 것이 맞습니다. + +## 6. 구조화 평가 결과 + +평가 경로: + +```text +runs/20260520_hf_fp4_sidecar/project_eval_base_layer10_late5hf +``` + +모델: + +```text +base +layer10q4 +thinktop32_late5_hf_fp4 +``` + +### korean100 + +| model | pass | rate | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:| +| base | 88/100 | 88% | 123.01 | 31.85 | +| layer10q4 | 84/100 | 84% | 122.96 | 31.28 | +| thinktop32_late5_hf_fp4 | 84/100 | 84% | 120.12 | 30.94 | + +### control60 + +| model | pass | rate | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:| +| base | 60/60 | 100% | 59.25 | 32.08 | +| layer10q4 | 60/60 | 100% | 58.85 | 31.79 | +| thinktop32_late5_hf_fp4 | 60/60 | 100% | 58.08 | 31.30 | + +### exact_long_extra + +| model | pass | rate | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:| +| base | 10/30 | 33.33% | 138.25 | 31.50 | +| layer10q4 | 10/30 | 33.33% | 134.12 | 31.38 | +| thinktop32_late5_hf_fp4 | 10/30 | 33.33% | 133.41 | 30.72 | + +해석: + +- 새 sidecar는 control 퇴화를 만들지 않았습니다. +- 속도는 base 대비 decode 약 2.9%, prefill 약 2.3% 정도 낮습니다. +- 한국어 held-out 100에서는 base를 못 이겼습니다. +- exact/long 추가 세트는 개선 없음입니다. + +## 7. Think MAX 30 결과 + +평가 경로: + +```text +runs/20260520_hf_fp4_sidecar/thinkmax30_base_layer10_late5hf +``` + +| model | pass | rate | avg prefill t/s | avg decode t/s | avg generated tokens | +|---|---:|---:|---:|---:|---:| +| base | 10/30 | 33.33% | 149.79 | 31.47 | 304.57 | +| layer10q4 | 17/30 | 56.67% | 149.94 | 31.05 | 319.70 | +| thinktop32_late5_hf_fp4 | 14/30 | 46.67% | 148.13 | 30.51 | 291.27 | + +Suite별: + +| model | control | exact | korean | long | +|---|---:|---:|---:|---:| +| base | 4/6 | 0/6 | 3/10 | 3/8 | +| layer10q4 | 4/6 | 0/6 | 8/10 | 5/8 | +| thinktop32_late5_hf_fp4 | 4/6 | 0/6 | 6/10 | 4/8 | + +해석: + +- 새 sidecar는 Think MAX에서 base보다 좋습니다. +- 하지만 Layer10Q4에는 못 미칩니다. +- Think MAX 후보로 "가능성 있음"이지만, 현재 best는 아닙니다. + +## 8. KMMLU 100 결과 + +평가 경로: + +```text +runs/20260520_hf_fp4_sidecar/kmmlu100_base_layer10_late5hf +``` + +| model | correct | accuracy | invalid | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:|---:| +| base | 70/100 | 70% | 0 | 152.94 | 31.08 | +| layer10q4 | 79/100 | 79% | 0 | 150.19 | 30.83 | +| thinktop32_late5_hf_fp4 | 72/100 | 72% | 0 | 150.22 | 30.21 | + +해석: + +- 새 sidecar는 KMMLU 100에서 base보다 +2점입니다. +- Layer10Q4는 +9점으로 더 강합니다. +- invalid prediction은 0이라 객관식 출력 안정성은 괜찮습니다. + +## 9. 속도 판단 + +전체적으로 새 sidecar는 속도 손실이 작습니다. + +- nothink structured eval decode: base 31.85 t/s, sidecar 30.94 t/s +- Think MAX decode: base 31.47 t/s, sidecar 30.51 t/s +- KMMLU decode: base 31.08 t/s, sidecar 30.21 t/s + +대략 2.5~3.5% 수준의 decode 손실입니다. 2.1GiB sidecar를 추가 매핑했지만 Metal runtime 경로는 안정적으로 유지됐습니다. + +## 10. 한계 + +현재 local source shard가 late 5개 layer뿐입니다. + +```text +available: L37, L38, L40, L41, L42 +missing for full Mixed32/ThinkTop32: 대부분의 early/mid layer shards +``` + +이번 결과만으로는 "한국어 expert bit-lift의 최종 답"을 고를 수 없습니다. 오히려 실험 결과는 Layer10 근방이 더 중요하다는 쪽을 지지합니다. + +또한 현재 source는 BF16 원본이 아니라 HF FP4+E8M0 source입니다. base GGUF보다 높은 출발점이지만, 진짜 BF16/FP16에서 Q4로 직접 내린 결과와는 다를 수 있습니다. + +## 11. 추천 다음 단계 + +1. 일반 chat/nothink 기본값은 `base` 또는 기존 안정 후보 유지. +2. Think MAX 한국어 실험은 계속 `Layer10Q4`를 1순위로 유지. +3. 이번 `ThinkTop32-Late5-HF-FP4`는 폐기하지 말고 source-sidecar pipeline 검증용 baseline으로 보관. +4. 다음 생성 후보는 `Layer10` 또는 `L8-L12` 원본 shard 기반 sidecar. +5. 가능하면 전체 checkpoint를 저장하지 말고 필요한 shard만 순차 다운로드/변환/삭제하는 streaming 방식으로 진행. +6. 이후 `KMMLU 300`, `Think MAX 30`, `long-instruction v2`를 Layer10 source-sidecar 후보와 다시 비교. + +## 12. 실사용 명령 + +inspect: + +```bash +./ds4 -m ds4flash.gguf \ + --bitlift-sidecar gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Late5-HF-FP4.sidecar.gguf \ + --inspect +``` + +nothink: + +```bash +./ds4 -m ds4flash.gguf \ + --bitlift-sidecar gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Late5-HF-FP4.sidecar.gguf \ + --nothink -n 256 --temp 0 \ + -p '한국어로 답하세요. 자료구조에서 스택과 큐의 차이를 설명해 주세요.' +``` + +Think MAX: + +```bash +./ds4 -m ds4flash.gguf \ + --bitlift-sidecar gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Late5-HF-FP4.sidecar.gguf \ + --think-max --ctx 393216 -n 512 --temp 0 \ + -p '한국어로 답하세요. 보안 업데이트가 중요한 이유를 설명해 주세요.' +``` + +trace: + +```bash +DS4_BITLIFT_TRACE_HITS=1 ./ds4 -m ds4flash.gguf \ + --bitlift-sidecar gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Late5-HF-FP4.sidecar.gguf \ + --think-max --ctx 393216 -n 128 --temp 0 \ + -p '한국어로 짧게 답하세요. 입력 검증과 출력 인코딩의 차이는 무엇인가요?' +``` + diff --git a/reports/ds4_kmmlu_sample100_report_20260519.md b/reports/ds4_kmmlu_sample100_report_20260519.md new file mode 100644 index 00000000..76a7877d --- /dev/null +++ b/reports/ds4_kmmlu_sample100_report_20260519.md @@ -0,0 +1,99 @@ +# DS4 KMMLU Sample 100 Evaluation + +날짜: 2026-05-19 +작업 디렉터리: `/Users/kch3dri4n/llm_provide/ds4` +결과 디렉터리: `/tmp/ds4-ko-cal/kmmlu_sample100_20260519` + +## 목적 + +KMMLU 원본 test split에서 seed 고정 샘플 100문항을 뽑아, 현재 로컬 GGUF 후보들이 한국어 지식형 객관식 문제에서 퇴화하는지 확인했습니다. + +이번 평가는 전체 KMMLU 벤치마크가 아니라 100문항 smoke/regression 평가입니다. 따라서 leaderboard 점수로 해석하면 안 되고, base 대비 GGUF 변형 간 상대 변화만 보는 용도입니다. + +## 데이터와 샘플링 + +- 데이터셋: `HAERAE-HUB/KMMLU` +- 사용 split: 각 subject의 `*-test.csv` +- 샘플 수: 100 +- 샘플 seed: `20260519` +- 샘플 파일: `/tmp/ds4-ko-cal/kmmlu_sample100_20260519/kmmlu_sample100.jsonl` +- 총 포함 subject 수: 35 + +정답 분포는 다음과 같습니다. + +| 정답 | 개수 | +|---:|---:| +| 1 | 14 | +| 2 | 17 | +| 3 | 34 | +| 4 | 35 | + +샘플 수가 많은 subject는 `Industrial-Engineer` 8개, `Law` 6개, `Refrigerating-Machinery` 6개, `Nondestructive-Testing` 5개입니다. 나머지 subject는 대부분 1~4개입니다. + +## 평가 설정 + +모든 모델은 같은 프롬프트와 같은 실행 조건으로 평가했습니다. + +```text +backend: --metal +mode: --nothink +ctx: 4096 +temperature: 0 +seed: 1 +max generated tokens per question: 8 +``` + +프롬프트는 “정답 번호만 출력”하도록 제한했습니다. 채점은 모델 출력에서 첫 번째 `1~4` 또는 `A~D`를 추출해 KMMLU의 정답 번호와 비교했습니다. + +## 비교 모델 + +| 이름 | GGUF | +|---|---| +| base | `/Users/kch3dri4n/llm_provide/ds4/ds4flash.gguf` | +| worst5q4 | `/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf` | +| latestable5q4 | `/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-LateStable5Q4-chat-v2.gguf` | + +## 결과 요약 + +| 모델 | 정답 / 100 | 정확도 | invalid | 평균 prefill t/s | 평균 decode t/s | 평균 prompt tokens | 평균 생성 tokens | +|---|---:|---:|---:|---:|---:|---:|---:| +| base | 69 / 100 | 0.690 | 0 | 152.91 | 31.13 | 178.1 | 1.0 | +| worst5q4 | 71 / 100 | 0.710 | 0 | 149.86 | 30.84 | 178.1 | 1.0 | +| latestable5q4 | 69 / 100 | 0.690 | 0 | 151.30 | 31.00 | 178.1 | 1.0 | + +속도는 세 모델이 사실상 같은 범위입니다. Worst5Q4와 LateStable5Q4는 base 대비 prefill/decode 모두 큰 손실이 없었습니다. + +## Base 대비 변화 + +| 모델 | base가 틀리고 해당 모델이 맞힌 문항 | base가 맞고 해당 모델이 틀린 문항 | 순증감 | +|---|---:|---:|---:| +| worst5q4 | 7 | 5 | +2 | +| latestable5q4 | 2 | 2 | 0 | + +세 모델이 모두 맞힌 문항은 63개, 세 모델이 모두 틀린 문항은 22개였습니다. + +## 해석 + +이번 KMMLU 100문항 smoke sample에서는 `worst5q4`가 base보다 2문항 높게 나왔고, `latestable5q4`는 base와 동률이었습니다. 다만 100문항이고 subject별 표본 수가 작기 때문에, Worst5Q4가 실제로 KMMLU에서 우수하다고 단정할 수는 없습니다. + +중요한 점은 이전 한국어 장문 지시문 평가에서 Worst5Q4가 크게 약했던 것과 달리, KMMLU식 짧은 객관식 지식 문제에서는 퇴화 신호가 보이지 않았다는 점입니다. 즉 Worst5Q4의 문제는 “한국어 전반 지식 능력”보다는 장문 지시 이행, 형식 유지, 생성 안정성 쪽에 더 가까워 보입니다. + +LateStable5Q4는 KMMLU sample에서 base와 동률이고 속도 손실도 작았습니다. 하지만 이전 Think MAX와 장문 지시 평가까지 합치면 아직 최종 후보로 승격하기에는 근거가 부족합니다. + +## 산출물 + +```text +/tmp/ds4-ko-cal/kmmlu_sample100_20260519/kmmlu_sample100.jsonl +/tmp/ds4-ko-cal/kmmlu_sample100_20260519/raw_results.jsonl +/tmp/ds4-ko-cal/kmmlu_sample100_20260519/scores.csv +/tmp/ds4-ko-cal/kmmlu_sample100_20260519/summary.json +/tmp/ds4-ko-cal/kmmlu_sample100_20260519/REPORT.md +/Users/kch3dri4n/llm_provide/ds4/tools/eval_kmmlu_sample.py +``` + +## 다음 권장 작업 + +1. 같은 스크립트로 seed를 3개 이상 바꿔 `3 x 100` 또는 `5 x 100` 반복 측정합니다. +2. subject별 stratified sample을 만들어 특정 분야 과표집 영향을 줄입니다. +3. `KR-Layer10Q4`를 만들 경우, 같은 KMMLU sample으로 base/Worst5Q4/LateStable5Q4/Layer10Q4를 동시에 비교합니다. +4. KMMLU에서 좋아져도 장문 지시문이 무너지면 채택하지 않는 기준을 유지합니다. diff --git a/reports/ds4_ko_gguf_project_report_20260518.md b/reports/ds4_ko_gguf_project_report_20260518.md new file mode 100644 index 00000000..cc5f981c --- /dev/null +++ b/reports/ds4_ko_gguf_project_report_20260518.md @@ -0,0 +1,491 @@ +# DS4 한국어 Expert Trace 및 GGUF 1차 결과 보고서 + +작성일: 2026-05-18 +작업 경로: `/Users/kch3dri4n/llm_provide/ds4` + +## 1. 요약 + +이번 작업의 목표는 한국어 캘리브레이션 데이터셋을 사용해 DeepSeek V4 Flash 계열 모델의 routed expert 사용 패턴을 측정하고, 실제 trace 기반으로 한국어 품질 개선 후보 expert를 선정한 뒤, 이를 GGUF 변환 실험까지 연결하는 것이었습니다. + +현재까지 완료된 핵심 산출물은 다음과 같습니다. + +- 한국어 calibration prompt 512개 기반 prefill/decode expert usage trace 기능 구현 +- nothink prefill, think prefill, nothink decode, think/high decode routing trace 측정 +- trace 기반 hot expert 및 bit-lift 추천 manifest 생성 +- 현재 GGUF/runtime 구조에서 즉시 실행 가능한 layer-level Q4 lift GGUF 생성 +- 생성 GGUF의 로딩, 실제 생성, routing coverage, 한국어 품질, prefill/decode 속도 검증 + +최종 생성 GGUF는 다음 파일입니다. + +```text +/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf +``` + +파일 크기는 다음과 같습니다. + +```text +95,779,807,840 bytes +89.20 GiB +``` + +## 2. 데이터셋 + +사용한 한국어 calibration 데이터셋은 총 512개 prompt로 구성되었습니다. 주요 범주는 다음과 같습니다. + +- 일상 대화 및 문자 작성 +- 한국어 문체 변환 +- 독해 요약 및 근거 추출 +- 한국어 문화/지식 문항 +- 코딩/보안 설명 +- 정확 복사 및 형식 보존 +- 장문 지시문 준수 + +대표적인 평가 목적은 다음과 같습니다. + +- 한국어 자연성 +- 공손체 및 문자 작성 품질 +- 긴 문맥 지시문 준수 +- 한글/자모/공백/표/JSON 형식 보존 +- 코딩/보안 개념 설명 품질 +- prefill과 decode 단계에서의 routed expert 사용 차이 확인 + +원본 데이터와 렌더링 결과는 다음 위치에 있습니다. + +```text +/tmp/ds4-ko-cal/ko_cal_prompts.jsonl +/tmp/ds4-ko-cal/rendered/rendered_prompts_nothink.txt +/tmp/ds4-ko-cal/rendered-think/rendered_prompts_think.txt +``` + +## 3. 구현 변경 + +expert usage trace를 위해 다음 파일을 수정했습니다. + +```text +/Users/kch3dri4n/llm_provide/ds4/ds4.c +/Users/kch3dri4n/llm_provide/ds4/ds4.h +/Users/kch3dri4n/llm_provide/ds4/ds4_cli.c +/Users/kch3dri4n/llm_provide/ds4/gguf-tools/imatrix/dataset/build_ds4_imatrix_dataset.py +``` + +추가된 주요 CLI 옵션은 다음과 같습니다. + +```text +--expert-usage-out FILE +--expert-usage-decode-tokens N +``` + +기능 요약: + +- routed MoE expert 선택 count 수집 +- router weight sum 수집 +- prefill prompt token 기준 trace +- greedy decode token 기준 trace +- CSV 형식으로 layer/expert별 count share 및 weight share 출력 + +## 4. Routing Trace 측정 결과 + +측정한 1차 trace 파일은 다음과 같습니다. + +```text +/tmp/ds4-ko-cal/expert_usage.csv +/tmp/ds4-ko-cal/expert_usage_think.csv +/tmp/ds4-ko-cal/expert_usage_decode_nothink_128.csv +/tmp/ds4-ko-cal/expert_usage_decode_think_128.csv +``` + +decode trace 토큰 수는 다음과 같이 관측되었습니다. + +```text +nothink decode: 52,315 decode tokens +think/high decode: 64,693 decode tokens +``` + +중요한 관찰은 다음과 같습니다. + +- prefill hot expert만 보고 bit-lift 대상을 고르는 것은 위험합니다. +- prefill think/nothink의 top4는 전 layer에서 거의 동일했지만, decode nothink와 decode think/high의 top4는 37/43 layers에서 달라졌습니다. +- prefill과 decode 간 top expert 일치도가 낮아서, decode 품질 개선 후보는 prefill-only trace로는 많이 누락됩니다. + +반복적으로 강하게 등장한 stable core expert는 다음입니다. + +```text +L40:E037 +L41:E184 +L38:E021 +L37:E025 +L42:E032 +``` + +## 5. Bit-Lift Manifest 산출물 + +추천 manifest는 다음 위치에 생성했습니다. + +```text +/tmp/ds4-ko-cal/bitlift_recommendation/ +``` + +주요 manifest는 다음과 같습니다. + +```text +bitlift_mixed_top32_skip_existing4.json +bitlift_mixed_top24_skip_existing4.json +bitlift_think_priority_top32_skip_existing4.json +bitlift_nothink_priority_top32_skip_existing4.json +bitlift_union_top8_prefill_decode_nothink_decode_think_skip_existing4.json +bitlift_union_top12_prefill_decode_nothink_decode_think_skip_existing4.json +bitlift_stable_core_intersection_top8_all4modes_skip_existing4.json +global_stable_top100_all4modes.csv +``` + +추천 순위는 다음과 같습니다. + +| 단계 | Manifest | Pairs | 예상 추가 용량 | 용도 | +|---|---|---:|---:|---| +| P0 | union_top8 | 479 | 약 +2.57 GiB | 변환 파이프라인 smoke test | +| P1 | mixed_top24 | 912 | 약 +4.90 GiB | 메모리 절약형 실전 후보 | +| P2 | mixed_top32 | 1216 | 약 +6.53 GiB | 균형형 한국어 품질 후보 | +| P2-think | think_priority_top32 | 1216 | 약 +6.53 GiB | thinking/high 위주 | +| P2-nothink | nothink_priority_top32 | 1216 | 약 +6.53 GiB | 일반 chat/nothink 위주 | + +가장 추천하는 장기 목표는 다음입니다. + +```text +DeepSeek-V4-Flash-JANGTQ-KR-Mixed32 +``` + +다만 이 목표는 expert 단위 Q4 sidecar 또는 mixed tensor runtime 지원이 필요합니다. + +## 6. GGUF 생성 + +원본 HF 모델은 다음에서 다운로드했습니다. + +```text +deepseek-ai/DeepSeek-V4-Flash +``` + +다운로드 결과: + +```text +46 safetensors +약 149 GiB +``` + +현재 quantizer와 runtime은 routed expert tensor 전체 단위 qtype을 사용합니다. 따라서 `mixed_top32`처럼 layer 내부의 특정 expert만 4bit로 올리는 GGUF는 현재 구조로 바로 표현할 수 없습니다. + +그래서 1차 GGUF는 즉시 실행 가능한 layer 단위 Q4 lift로 생성했습니다. + +Q4_K 적용 layer: + +```text +L23 +L25 +L28 +L34 +L36 +``` + +각 layer에서 다음 3개 routed tensor를 모두 Q4_K로 올렸습니다. + +```text +ffn_gate_exps.weight +ffn_down_exps.weight +ffn_up_exps.weight +``` + +GGUF inspect 결과: + +```text +file size: 89.20 GiB +tensor bytes described by GGUF: 89.20 GiB +logical parameters: 284.33 B + +tensor types: + f32 492 tensors, 0.00 GiB + f16 359 tensors, 2.04 GiB + q8_0 345 tensors, 6.15 GiB + q2_k 38 tensors, 24.94 GiB + q4_k 15 tensors, 16.88 GiB + iq2_xxs 76 tensors, 39.19 GiB + i32 3 tensors, 0.01 GiB +``` + +qtype 검증 결과: + +```text +L23: gate=Q4_K down=Q4_K up=Q4_K +L25: gate=Q4_K down=Q4_K up=Q4_K +L28: gate=Q4_K down=Q4_K up=Q4_K +L34: gate=Q4_K down=Q4_K up=Q4_K +L36: gate=Q4_K down=Q4_K up=Q4_K +non_target_q4_count=0 +bad_count=0 +``` + +## 7. GGUF Smoke Test + +짧은 한국어 생성 테스트를 실행했습니다. + +Prompt: + +```text +한국어로 한 문장만 답해 주세요. 오늘 작업 상태는? +``` + +Output: + +```text +오늘 작업 상태는 순조롭게 진행 중입니다. +``` + +속도: + +```text +prefill: 77.57 t/s +generation: 32.06 t/s +``` + +이 결과로 모델 로딩, Metal backend, prefill, decode, 한국어 출력이 모두 정상임을 확인했습니다. + +## 8. 새 GGUF 기준 Routing Coverage + +평가 결과 위치: + +```text +/tmp/ds4-ko-cal/gguf_eval_worst5q4/ +``` + +prefill trace: + +```text +128 prompts +10,107 prompt tokens +2,607,606 routed expert observations +``` + +decode trace: + +```text +32 prompts +1,932 prompt tokens +3,986 decode tokens +1,028,388 routed expert observations +``` + +manifest 후보 활성화율: + +```text +prefill mixed_top32: 1204 / 1216 = 99.01% +decode mixed_top32: 1204 / 1216 = 99.01% + +prefill union_top8: 474 / 479 = 98.96% +decode union_top8: 474 / 479 = 98.96% +``` + +Q4로 올린 layer의 expert 활성 폭: + +| Mode | L23 | L25 | L28 | L34 | L36 | +|---|---:|---:|---:|---:|---:| +| prefill | 235/256 | 227/256 | 231/256 | 237/256 | 236/256 | +| decode | 227/256 | 233/256 | 236/256 | 219/256 | 218/256 | + +stable core top5 활성화: + +```text +L40:E037 prefill=9175 decode=3984 +L41:E184 prefill=9134 decode=3976 +L38:E021 prefill=9185 decode=3979 +L37:E025 prefill=9146 decode=3974 +L42:E032 prefill=9151 decode=3975 +``` + +판단: + +- 우리가 지목한 주요 expert들은 실제 prefill/decode에서 강하게 활성화됩니다. +- Q4 적용 layer들도 특정 expert 몇 개만 쓰이는 것이 아니라 layer당 218~237개 expert가 관측되었습니다. +- layer-level Q4 lift가 최소한 “죽은 layer를 올린 것”은 아니며, 실제 routing에서 자주 쓰이는 영역을 포함합니다. + +## 9. 속도 비교 + +비교 대상: + +```text +base: ds4flash.gguf +worst5q4: DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf +``` + +짧은 decode 128 테스트: + +| Model | Prefill t/s | Decode t/s | +|---|---:|---:| +| base | 71.34 | 31.76 | +| worst5q4 | 70.30 | 31.64 | + +prefill-heavy 테스트: + +| Model | Prefill t/s | +|---|---:| +| base | 327.49 | +| worst5q4 | 327.18 | + +판단: + +- Q4 layer 5개 추가로 인한 decode 속도 저하는 거의 없습니다. +- prefill 속도도 거의 동일합니다. +- M4 Max 128GB 환경에서 현재 89.20 GiB GGUF는 실행 가능하고 속도도 양호합니다. + +## 10. 한국어 품질 점검 + +품질 샘플 결과: + +```text +/tmp/ds4-ko-cal/gguf_eval_worst5q4/quality_samples.json +/tmp/ds4-ko-cal/gguf_eval_worst5q4/quality_deep_samples.json +``` + +exact-copy strict pass: + +```text +6 / 7 +``` + +통과 항목: + +```text +single_line_copy +multiline_copy +json_like_copy +markdown_table_copy +whitespace_copy +tag_copy +``` + +실패 항목: + +```text +jamo_copy +``` + +실패 내용: + +```text +expected: 초성열: ... +got: <초성열: ... +``` + +즉 자모 자체는 보존했지만, 원래 제거해야 했던 꺾쇠괄호 `<`를 같이 복사했습니다. + +일상 대화 샘플은 공손하고 자연스러웠습니다. 예를 들어 조별과제 자료 요청 문항에서는 짧고 구체적인 요청으로 끝났습니다. + +독해 요약 샘플도 원문에 없는 내용을 크게 추가하지 않고 3문장 요약 형식을 지켰습니다. + +코딩/보안 설명은 개념 설명 품질은 양호했습니다. 다만 SQL prepared statement 예시에서 설명용 입력 문자열이 공격 예시처럼 보일 수 있어, 엄격한 방어-only 평가에서는 감점 여지가 있습니다. + +장문 지시문 샘플은 전체 구조는 괜찮았지만, “표 사용 금지” 조건이 있는 상황에서 마지막 문장에 “표로 정리”를 언급했습니다. 따라서 long instruction compliance는 추가 개선/재평가가 필요합니다. + +## 11. 현재 한계 + +가장 큰 한계는 GGUF/runtime 표현 단위입니다. + +현재 구조: + +```text +layer 전체 routed expert tensor 하나가 하나의 qtype을 가짐 +``` + +원하는 최종 구조: + +```text +같은 layer 안에서 특정 expert만 Q4, 나머지는 Q2/IQ2 유지 +``` + +이 차이 때문에 `mixed_top32` manifest를 그대로 GGUF에 반영하지 못했습니다. 현재 만든 GGUF는 `mixed_top32`가 아니라, 실행 가능한 1차 smoke 모델인 `Worst5Q4`입니다. + +true expert-level bit-lift를 하려면 다음이 필요합니다. + +- Q4 sidecar expert tensor 포맷 +- sidecar expert id mapping metadata +- runtime layer weight 구조 확장 +- routing 결과에서 base tensor와 sidecar tensor를 나누는 split path +- Metal MoE kernel 또는 dispatch path 수정 +- CPU/GPU validation test + +## 12. 다음 계획 + +### P0: 현재 Worst5Q4 보존 및 비교 평가 + +현재 GGUF는 보존 가치가 있습니다. 속도 저하 없이 Q4 layer lift가 가능함을 확인했기 때문입니다. + +해야 할 일: + +- 기존 base vs Worst5Q4 held-out 100개 한국어 prompt 비교 +- exact-copy 세트 확대 +- 장문 지시문 준수율 측정 +- 영어/중국어/control prompt degradation check + +### P1: Expert-Level Mixed32 포맷 설계 + +다음 목표는 `mixed_top32`를 실제로 반영하는 것입니다. + +권장 설계: + +```text +base tensor: 기존 IQ2/Q2 full expert tensor 유지 +sidecar tensor: selected expert만 Q4_K로 별도 저장 +metadata: layer별 selected expert id list 저장 +runtime: routing된 expert id가 sidecar에 있으면 Q4 path 사용 +``` + +예상 sidecar tensor 이름: + +```text +blk.N.ffn_gate_exps.bitlift_q4.weight +blk.N.ffn_down_exps.bitlift_q4.weight +blk.N.ffn_up_exps.bitlift_q4.weight +blk.N.ffn_exps.bitlift_q4.ids +``` + +### P2: KR-Mixed32 생성 + +목표 모델명: + +```text +DeepSeek-V4-Flash-JANGTQ-KR-Mixed32 +``` + +내용: + +```text +기존 IQ2XXS-w2Q2K 기반 유지 +나머지 38개 2bit layer에서 mixed score 상위 32 experts/layer만 Q4 sidecar 생성 +L23/L25/L28/L34/L36은 별도 정책 적용 +``` + +### P3: 최종 평가 + +최소 평가 항목: + +- 한국어 held-out 100개 +- exact-copy 64개 이상 +- 장문 지시문 40개 이상 +- 한국어 코딩/보안 설명 40개 이상 +- 영어/중국어 degradation 50~100개 +- prefill/decode speed +- stable expert routing coverage +- sidecar hit ratio + +## 13. 최종 판단 + +현재 단계의 결론은 다음과 같습니다. + +```text +1. 한국어 expert trace 기능은 정상 동작한다. +2. prefill-only 기준은 부족하고 decode trace가 반드시 필요하다. +3. trace 기반 mixed_top32 후보는 실제 routing에서 거의 전부 활성화된다. +4. 현재 runtime에서 바로 가능한 layer-level Q4 GGUF 생성은 성공했다. +5. 생성 GGUF는 로딩, 생성, 속도, routing coverage 모두 양호하다. +6. 한국어 일반 품질은 괜찮지만 exact-copy와 장문 지시문 준수는 추가 검증이 필요하다. +7. 진짜 목표인 expert-level KR-Mixed32는 GGUF/runtime sidecar 설계가 다음 핵심 작업이다. +``` + +현재 결과는 “감으로 한국어 expert를 찍는 단계”를 넘어서, trace 기반으로 lift 후보를 고르고 실제 GGUF 실험까지 연결한 1차 성공 상태입니다. 다음 작업의 핵심은 layer 단위 lift가 아니라 expert 단위 mixed lift를 런타임에 실제로 먹이는 것입니다. diff --git a/reports/ds4_l10_base_fp8_source_sidecar_20260521.md b/reports/ds4_l10_base_fp8_source_sidecar_20260521.md new file mode 100644 index 00000000..322549e9 --- /dev/null +++ b/reports/ds4_l10_base_fp8_source_sidecar_20260521.md @@ -0,0 +1,127 @@ +# DS4 L10 Base-FP8 Source Sidecar 평가 보고서 + +작성일: 2026-05-21 +작업 위치: `/Users/kch3dri4n/llm_provide/ds4` +실험 산출물 위치: `/Volumes/Back_UP_LLM/ds4-source-fp8-sidecar` + +## 결론 + +이번 실험의 핵심 결론은 명확합니다. `deepseek-ai/DeepSeek-V4-Flash-Base`의 official Base FP8 shard에서 Layer 10 routed expert를 직접 읽어 Q4_K sidecar로 만드는 파이프라인은 성공했습니다. 세 sidecar 모두 로드, inspect, Think MAX routing trace, structured eval, KMMLU300, Think MAX 30, long instruction v2까지 런타임 안정성은 통과했습니다. + +하지만 품질 기준으로는 새 Base-FP8 L10 sidecar가 최종 추천 모델이 아닙니다. 일반 chat/nothink는 `base` 또는 기존 `LateStable5Q4` 유지가 맞고, Think MAX 및 KMMLU 계열은 기존 `Layer10Q4`가 여전히 가장 강합니다. 새 Base-FP8 L10 sidecar는 실사용 추천 모델이 아니라, source-based sidecar writer/runtime 파이프라인을 검증한 실험 산출물로 보관하는 것이 맞습니다. + +## 생성한 sidecar + +| artifact | coverage | size | SHA256 | +|---|---:|---:|---| +| `DeepSeek-V4-Flash-KR-ThinkTop64-L10-BaseFP8-Q4.sidecar.gguf` | L10 top64 routed experts | 864M | `fb3c755c658e39287424dfe85cc9bfe0fbcc8a4bfbaf775ed0263e4640de2f0e` | +| `DeepSeek-V4-Flash-KR-ThinkTop128-L10-BaseFP8-Q4.sidecar.gguf` | L10 top128 routed experts | 1.7G | `74bc248b0ff480f4a56066a73693b15e8dcfdb8e0d5608119ada730f58db888b` | +| `DeepSeek-V4-Flash-KR-Full256-L10-BaseFP8-Q4.sidecar.gguf` | L10 all 256 routed experts | 3.4G | `b22a14ab2bedf72ef31968168186f8c937c229c3e2bd38b9fb55346b903ee94a` | + +원본 source shard는 `model-00012-of-00046.safetensors`이며, Layer 10 routed expert tensor는 `F8_E4M3` weight와 `F32` block scale 조합입니다. 따라서 이번 실험은 full BF16 원본 기반이 아니라 official Base FP8 source 기반입니다. + +## 로드 및 routing trace + +`./ds4 -m ds4flash.gguf --bitlift-sidecar ... --inspect` 기준 세 artifact 모두 정상 로드되었습니다. + +| artifact | sidecar mapped memory | loaded layers | expert slots | +|---|---:|---:|---:| +| Top64 | 864.02 MiB | 1 | 64 | +| Top128 | 1728.02 MiB | 1 | 128 | +| Full256 | 3456.02 MiB | 1 | 256 | + +Think MAX routing trace도 정상 동작했습니다. + +| artifact | route events | base routes | sidecar routes | sidecar route rate | unique sidecar slots | prefill t/s | decode t/s | +|---|---:|---:|---:|---:|---:|---:|---:| +| Top64 | 266 | 553 | 1043 | 0.654 | 55 | 114.06 | 30.91 | +| Top128 | 265 | 411 | 1179 | 0.742 | 91 | 115.13 | 30.99 | +| Full256 | 266 | 0 | 1596 | 1.000 | 166 | 118.87 | 31.10 | + +해석: 전문가 선택 경로는 정상적으로 sidecar를 타고 있습니다. 특히 Full256은 L10 routed expert가 전부 sidecar에 존재하므로 route hit rate가 100%였습니다. 즉 문제는 dispatch/로드가 아니라, 어떤 source와 어느 layer/expert를 올렸을 때 실제 품질이 좋아지는지의 문제입니다. + +## Structured 평가 + +평가 파일: `runs/20260521_l10_base_fp8_source/eval_structured/summary.json` + +| suite | model | pass | prefill t/s | decode t/s | +|---|---|---:|---:|---:| +| korean100 | base | 88/100 | 123.87 | 32.11 | +| korean100 | Top64 BaseFP8 sidecar | 85/100 | 122.93 | 31.33 | +| korean100 | Top128 BaseFP8 sidecar | 85/100 | 123.24 | 31.37 | +| korean100 | Full256 BaseFP8 sidecar | 80/100 | 123.53 | 31.32 | +| control60 | base | 60/60 | 59.43 | 32.59 | +| control60 | Top64 BaseFP8 sidecar | 60/60 | 58.98 | 31.69 | +| control60 | Top128 BaseFP8 sidecar | 60/60 | 59.35 | 31.70 | +| control60 | Full256 BaseFP8 sidecar | 60/60 | 59.42 | 31.71 | +| exact_long_extra | base | 10/30 | 137.76 | 31.81 | +| exact_long_extra | Top64 BaseFP8 sidecar | 10/30 | 137.06 | 31.16 | +| exact_long_extra | Top128 BaseFP8 sidecar | 10/30 | 137.64 | 31.18 | +| exact_long_extra | Full256 BaseFP8 sidecar | 10/30 | 137.55 | 31.16 | + +해석: control 퇴화와 exact-copy 추가 악화는 관찰되지 않았습니다. 다만 korean100에서 base 대비 Top64/Top128은 -3, Full256은 -8로 떨어졌습니다. 일반 한국어 chat/nothink 개선 후보로는 탈락입니다. + +## KMMLU 300 + +평가 파일: `runs/20260521_l10_base_fp8_source/eval_kmmlu300/summary.json` + +| model | correct | accuracy | invalid | prefill t/s | decode t/s | +|---|---:|---:|---:|---:|---:| +| base | 209/300 | 0.697 | 0 | 155.38 | 30.96 | +| Layer10Q4 | 212/300 | 0.707 | 1 | 152.42 | 30.60 | +| Top64 BaseFP8 sidecar | 205/300 | 0.683 | 1 | 154.50 | 30.75 | +| Top128 BaseFP8 sidecar | 205/300 | 0.683 | 0 | 154.60 | 30.77 | +| Full256 BaseFP8 sidecar | 205/300 | 0.683 | 0 | 154.77 | 30.71 | + +해석: KMMLU에서는 기존 `Layer10Q4`가 최상입니다. 새 Base-FP8 sidecar 3종은 모두 base보다 낮습니다. 원본 source에서 sidecar를 만들었다는 점만으로 품질이 좋아지지는 않았고, L10 단일 layer coverage 확대는 이 평가에서 효과가 없었습니다. + +## Think MAX 30 + +평가 파일: `runs/20260521_l10_base_fp8_source/eval_thinkmax30/thinkmax_basefp8_cmp_summary.json` + +| model | pass | korean subset | long subset | control subset | prefill t/s | decode t/s | +|---|---:|---:|---:|---:|---:|---:| +| base | 10/30 | 3/10 | 3/8 | 4/6 | 148.16 | 31.55 | +| Layer10Q4 | 17/30 | 8/10 | 5/8 | 4/6 | 145.62 | 31.12 | +| Top64 BaseFP8 sidecar | 14/30 | 5/10 | 5/8 | 4/6 | 147.37 | 31.26 | +| Top128 BaseFP8 sidecar | 12/30 | 2/10 | 6/8 | 4/6 | 147.50 | 31.25 | +| Full256 BaseFP8 sidecar | 10/30 | 0/10 | 6/8 | 4/6 | 148.56 | 31.19 | + +해석: Think MAX에서는 `Layer10Q4`가 확실히 우세합니다. Top128/Full256은 long subset은 괜찮지만 korean subset이 크게 무너졌습니다. 특히 Full256은 korean subset 0/10이라 실사용 후보로 두기 어렵습니다. + +## Long Instruction v2 + +평가 파일: `runs/20260521_l10_base_fp8_source/eval_longv2/summary.json` + +| model | pass | avg score | prefill t/s | decode t/s | +|---|---:|---:|---:|---:| +| base | 41/60 | 0.850 | 165.72 | 31.44 | +| Layer10Q4 | 27/60 | 0.820 | 163.98 | 31.08 | +| Top64 BaseFP8 sidecar | 34/60 | 0.819 | 165.87 | 31.15 | +| Top128 BaseFP8 sidecar | 30/60 | 0.806 | 164.71 | 31.10 | +| Full256 BaseFP8 sidecar | 36/60 | 0.821 | 164.42 | 31.23 | + +해석: 장문 지시문은 base가 가장 좋습니다. Layer10Q4는 Think MAX/KMMLU는 좋지만 장문 형식 준수에서 손해가 큽니다. Full256 Base-FP8 sidecar는 새 sidecar 중 장문에서는 가장 낫지만 base에는 못 미칩니다. + +## 속도와 메모리 + +세 Base-FP8 sidecar 모두 decode 속도는 대략 31 tok/s 선에서 안정적이었습니다. base 대비 큰 속도 붕괴는 없었습니다. sidecar 추가 메모리는 Top64 약 864 MiB, Top128 약 1.7 GiB, Full256 약 3.4 GiB입니다. 실제 총 mmap 기준은 base GGUF 약 80.76 GiB에 sidecar mapped memory가 추가되는 구조입니다. + +## 최종 운영 추천 + +1. 일반 chat/nothink: `base` 또는 기존 `LateStable5Q4` 유지. +2. Think MAX 한국어/KMMLU 실험: `Layer10Q4` 유지. +3. 새 Base-FP8 L10 Top64/Top128/Full256 sidecar: 실사용 추천 모델로 채택하지 않음. +4. sidecar runtime/writer: 유지할 가치 있음. 로드, inspect, routing hit, Q4_K tensor dispatch 기반은 검증됨. +5. 다음 실험: L10 단일 layer coverage 확대보다, Layer10Q4가 실제로 올린 multi-layer 구성의 원본 source 기반 재현이 더 논리적입니다. 즉 `L23/L25/L28/L34/L36/L37/L38/L40/L41/L42` 중심으로 source-based sidecar를 만드는 방향이 다음 후보입니다. + +## PR에 포함할 내용 + +- FP8 E4M3 + F32 block scale을 Q4_K로 변환하는 quant helper. +- HF Base-FP8 safetensors shard에서 routed expert tensor를 읽어 sidecar GGUF를 만드는 writer. +- 기존 eval/bench 스크립트에 Base-FP8 sidecar alias 추가. +- 본 보고서와 평가 결과 요약. + +## HF 업로드 정책 + +이번 산출물은 private HF repo에 업로드하되, 모델 카드에서 “최종 추천 모델”이 아니라 “source-based sidecar 파이프라인 검증 artifact”로 명시해야 합니다. 품질상 winner는 새 sidecar가 아니며, 현 시점 winner는 용도별로 base/LateStable5Q4/Layer10Q4입니다. diff --git a/reports/ds4_l10_wide_source_sidecar_20260520.md b/reports/ds4_l10_wide_source_sidecar_20260520.md new file mode 100644 index 00000000..665be277 --- /dev/null +++ b/reports/ds4_l10_wide_source_sidecar_20260520.md @@ -0,0 +1,302 @@ +# DS4 L10 Wide Source Sidecar Report + +작성일: 2026-05-20 +작업 경로: `/Users/kch3dri4n/llm_provide/ds4` +실험 대상: DeepSeek-V4-Flash JANGTQ-K 기반 L10 routed expert source sidecar + +## 1. 결론 + +이번 실험의 핵심 결론은 **L10 top32보다 L10 coverage를 넓히는 방향은 맞지만, L10 full256을 기본 채팅 후보로 쓰는 것은 아직 이르다**입니다. + +가장 균형 잡힌 후보는 `ThinkTop128-L10-HF-FP4`입니다. Think MAX 30개와 held-out 한국어 100개에서 가장 좋았고, KMMLU300에서도 198/300으로 이번 wide 후보 중 최고였습니다. 다만 장문 지시문 v2에서는 `ThinkTop64-L10-HF-FP4`가 41/60으로 가장 높아서, 장문 지시 추종만 놓고 보면 top64가 더 안정적입니다. + +`Full256-L10-HF-FP4`는 L10 routed expert 전체를 sidecar로 덮는 가장 깨끗한 구현 검증입니다. 실제 trace에서 L10 base route가 0이고 sidecar route가 100%로 확인되었습니다. 그러나 한국어 생성형 held-out에서는 76/100으로 크게 밀렸기 때문에, full coverage가 항상 품질 개선으로 이어지지는 않았습니다. + +최종 추천은 다음과 같습니다. + +- 일반 한국어 chat/nothink: 기존 `base` 또는 `LateStable5Q4` 유지 +- Think MAX 한국어 실험: `ThinkTop128-L10-HF-FP4`를 1순위 후보로 유지 +- 장문 지시문 특화: `ThinkTop64-L10-HF-FP4` 별도 후보 유지 +- 객관식/KMMLU 계열: `ThinkTop128-L10-HF-FP4`와 `Full256-L10-HF-FP4`를 둘 다 유지 +- 다음 구현: L10 full256을 바로 채팅 기본값으로 올리지 말고, L10 top64/top128/full256을 runtime selectable sidecar policy로 분리 + +## 2. 생성한 sidecar + +세 sidecar 모두 base GGUF에서 잘라낸 것이 아니라, 로컬 HF source shard에서 L10 expert tensor를 읽어 다시 sidecar GGUF로 쓴 것입니다. 단, 현재 확보한 source shard는 `HF-FP4` 경로이므로 “BF16/FP16 고정밀 원본”은 아닙니다. 이번 결과는 **base GGUF 복원 기반보다 source shard 기반에 가까운 sidecar**라는 의미로 해석해야 합니다. + +source shard: + +```text +/Volumes/Back_UP/hf-cache-offload/deepseek-ai/DeepSeek-V4-Flash-late5/model-00012-of-00046.safetensors +``` + +생성 파일: + +| 후보 | L10 expert coverage | 파일 크기 | 경로 | +|---|---:|---:|---| +| ThinkTop64 | 64/256 | 864M | `/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop64-L10-HF-FP4.sidecar.gguf` | +| ThinkTop128 | 128/256 | 1.7G | `/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop128-L10-HF-FP4.sidecar.gguf` | +| Full256 | 256/256 | 3.4G | `/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-Full256-L10-HF-FP4.sidecar.gguf` | + +repo 내 symlink: + +```text +gguf/DeepSeek-V4-Flash-KR-ThinkTop64-L10-HF-FP4.sidecar.gguf +gguf/DeepSeek-V4-Flash-KR-ThinkTop128-L10-HF-FP4.sidecar.gguf +gguf/DeepSeek-V4-Flash-KR-Full256-L10-HF-FP4.sidecar.gguf +``` + +## 3. Expert 선정 방식 + +기존 uploaded trace 기반 `think_priority_top32`를 prefix로 유지했습니다. 이는 이전 측정에서 Think MAX decode 쪽 신호가 강했고, top32 후보가 이미 의미 있게 검증된 상태였기 때문입니다. + +top32 이후의 확장은 이번에 로컬에서 새로 수집한 L10 usage trace로 채웠습니다. + +수집한 trace: + +```text +runs/20260520_l10_wide_source/usage/prefill_think_usage.csv +runs/20260520_l10_wide_source/usage/decode_think128_usage.csv +``` + +확장 ranking: + +```text +runs/20260520_l10_wide_source/manifests/l10_local_think_usage_extended_ranking.csv +``` + +생성 manifest: + +```text +runs/20260520_l10_wide_source/manifests/l10_think_priority_top64_source_hf_fp4.json +runs/20260520_l10_wide_source/manifests/l10_think_priority_top128_source_hf_fp4.json +runs/20260520_l10_wide_source/manifests/l10_full256_source_hf_fp4.json +``` + +sidecar plan: + +```text +runs/20260520_l10_wide_source/sidecar_plan/l10_think_priority_top64_source_hf_fp4_sidecar_plan.json +runs/20260520_l10_wide_source/sidecar_plan/l10_think_priority_top128_source_hf_fp4_sidecar_plan.json +runs/20260520_l10_wide_source/sidecar_plan/l10_full256_source_hf_fp4_sidecar_plan.json +``` + +## 4. 로딩 검증 + +세 sidecar 모두 `./ds4 --inspect`에서 정상 로딩됐습니다. + +inspect 결과 요약: + +| 후보 | loaded layers | expert slots | tensor triplets | qtype | +|---|---:|---:|---:|---| +| ThinkTop64 | 1 | 64 | 3 | q4_k | +| ThinkTop128 | 1 | 128 | 3 | q4_k | +| Full256 | 1 | 256 | 3 | q4_k | + +관련 로그: + +```text +runs/20260520_l10_wide_source/top64_l10_hf_fp4_inspect.out +runs/20260520_l10_wide_source/top128_l10_hf_fp4_inspect.out +runs/20260520_l10_wide_source/full256_l10_hf_fp4_inspect.out +``` + +초기 병렬 inspect 실패는 sidecar 문제가 아니라 `ds4` 단일 실행 잠금 때문이었습니다. 순차 inspect에서는 모두 성공했습니다. + +## 5. Runtime activation trace + +Think MAX smoke prompt에서 `DS4_BITLIFT_TRACE_HITS=1`로 L10 route split을 확인했습니다. + +| 후보 | trace events | base routes | sidecar routes | sidecar route rate | unique sidecar slots observed | +|---|---:|---:|---:|---:|---:| +| ThinkTop64 | 230 | 479 | 901 | 65.29% | 54 | +| ThinkTop128 | 230 | 290 | 1090 | 78.99% | 90 | +| Full256 | 230 | 0 | 1380 | 100.00% | 160 | + +해석: + +- top64는 L10 route의 약 65%를 sidecar Q4로 받았습니다. +- top128은 약 79%까지 올라갔습니다. +- full256은 L10 routed expert 전체를 sidecar로 커버하므로 base route가 0입니다. +- full256에서 base route 0 상태로 prefill/decode가 정상 동작했으므로, runtime partition path는 빈 base partition도 처리합니다. + +trace summary: + +```text +runs/20260520_l10_wide_source/top64_l10_hf_fp4_thinkmax_trace_summary.json +runs/20260520_l10_wide_source/top128_l10_hf_fp4_thinkmax_trace_summary.json +runs/20260520_l10_wide_source/full256_l10_hf_fp4_thinkmax_trace_summary.json +``` + +## 6. 속도 + +짧은 Think MAX smoke: + +| 후보 | prefill t/s | generation t/s | +|---|---:|---:| +| ThinkTop64 | 113.62 | 30.78 | +| ThinkTop128 | 114.40 | 30.68 | +| Full256 | 117.21 | 31.07 | + +Think MAX 30: + +| 후보 | avg prefill t/s | avg generation t/s | +|---|---:|---:| +| ThinkTop64 | 148.36 | 31.38 | +| ThinkTop128 | 148.64 | 31.43 | +| Full256 | 149.12 | 31.37 | + +KMMLU300: + +| 후보 | avg prefill t/s | avg generation t/s | +|---|---:|---:| +| ThinkTop64 | 153.85 | 31.02 | +| ThinkTop128 | 153.73 | 31.01 | +| Full256 | 154.21 | 30.99 | + +장문 v2: + +| 후보 | avg prefill t/s | avg generation t/s | +|---|---:|---:| +| ThinkTop64 | 166.46 | 31.53 | +| ThinkTop128 | 166.21 | 31.82 | +| Full256 | 165.48 | 31.36 | + +속도 결론: + +세 후보 간 decode 속도 차이는 의미 있게 크지 않았습니다. sidecar 폭을 top64에서 full256까지 키워도 이번 측정에서는 generation throughput이 대략 31 tok/s 부근으로 유지되었습니다. 즉 이번 단계의 병목은 속도보다 품질/지시추종 안정성입니다. + +## 7. 품질 평가 요약 + +기존 기준선: + +| 평가 | base | Layer10Q4 | L10 source top32 | L8-L12 source top32 | +|---|---:|---:|---:|---:| +| Think MAX 30 | 10/30 | 17/30 | 9/30 | 14/30 | +| KMMLU300 | 197/300 | 210/300 | 197/300 | 182/300 | +| Korean100 | 88/100 | 84/100 | 85/100 | 83/100 | +| control60 | 60/60 | 60/60 | 60/60 | 60/60 | +| exact_long_extra | 10/30 | 10/30 | 9/30 | 8/30 | +| long v2 | 42/60 | 27/60 | 38/60 | 41/60 | + +이번 L10 wide 후보: + +| 평가 | ThinkTop64 | ThinkTop128 | Full256 | +|---|---:|---:|---:| +| Think MAX 30 | 13/30 | 15/30 | 11/30 | +| KMMLU100 | 69/100 | 70/100 | 74/100 | +| KMMLU300 | 194/300 | 198/300 | 197/300 | +| Korean100 | 83/100 | 87/100 | 76/100 | +| control60 | 60/60 | 60/60 | 60/60 | +| exact_long_extra | 9/30 | 9/30 | 9/30 | +| long v2 | 41/60 | 33/60 | 36/60 | + +## 8. 평가별 해석 + +### Think MAX 30 + +`ThinkTop128`이 15/30으로 wide 후보 중 가장 좋았습니다. 이전 `L10 source top32`가 9/30이었으므로, L10 coverage를 top128까지 넓힌 것은 Think MAX 쪽에서 유의미한 개선입니다. + +다만 기존 `Layer10Q4` 전체 레이어 변환 후보는 17/30이었습니다. 따라서 현 sidecar 방식은 top32 대비 개선됐지만, 아직 layer 전체 Q4 모델의 Think MAX 성능을 완전히 따라잡지는 못했습니다. + +### KMMLU + +KMMLU100에서는 full256이 74/100으로 앞섰습니다. 그러나 KMMLU300에서는 top128이 198/300, full256이 197/300으로 거의 동률입니다. 즉 full256의 KMMLU100 우세는 작은 표본에서 과대평가된 면이 있습니다. + +기존 `Layer10Q4`는 KMMLU300에서 210/300이었으므로, knowledge benchmark에서는 아직 전체 L10Q4가 가장 좋습니다. + +### Korean100 + +일반 한국어 held-out에서는 top128이 87/100으로 최고입니다. base 88/100보다는 1점 낮지만, Layer10Q4 84/100, L10 source top32 85/100보다 낫습니다. + +반대로 full256은 76/100으로 크게 하락했습니다. L10의 모든 expert를 source sidecar로 교체하는 것이 한국어 생성형 안정성에는 오히려 해로울 수 있다는 신호입니다. + +### Control60 + +세 후보 모두 60/60입니다. 이번 L10 sidecar는 영어/중국어/control prompt에서 즉각적인 퇴화는 보이지 않았습니다. + +### Exact/Long Extra + +세 후보 모두 9/30입니다. 기존 base와 Layer10Q4의 10/30보다 1점 낮습니다. exact-copy 계열은 여전히 취약하고, 이번 L10 wide sidecar만으로 해결되지 않았습니다. + +### Long V2 + +장문 지시문 v2에서는 top64가 41/60으로 가장 좋습니다. 이 값은 기존 L8-L12 source top32의 41/60과 동률이고, base 42/60에는 1점 낮습니다. + +top128은 33/60으로 오히려 낮습니다. L10 coverage를 top128까지 넓히면 일반 한국어/Think MAX에는 도움이 되지만, 장문 지시문 구조 보존에는 방해가 될 수 있습니다. + +## 9. 최종 판단 + +하나만 고르라면 `ThinkTop128-L10-HF-FP4`입니다. + +이유: + +- top32 source sidecar보다 Think MAX가 개선됨: 9/30 → 15/30 +- Korean100에서 wide 후보 중 최고: 87/100 +- KMMLU300에서도 wide 후보 중 최고: 198/300 +- control60 퇴화 없음: 60/60 +- 속도 손해 없음: decode 약 31 tok/s 유지 + +단, 장문 지시문만 보면 `ThinkTop64-L10-HF-FP4`가 낫습니다. 따라서 실제 runtime policy는 다음처럼 나누는 것이 좋습니다. + +```text +default chat / nothink: base 또는 LateStable5Q4 +Think MAX general Korean: ThinkTop128-L10-HF-FP4 +long instruction / report generation: ThinkTop64-L10-HF-FP4 +KMMLU / objective QA experiment: ThinkTop128-L10-HF-FP4 또는 Full256-L10-HF-FP4 +``` + +`Full256-L10-HF-FP4`는 버리면 안 됩니다. proof-of-runtime으로 매우 중요하고, L10 base route 0 처리가 검증됐습니다. 하지만 채팅 기본값으로 올리기에는 Korean100 하락이 큽니다. + +## 10. 한계 + +이번 source sidecar는 `HF-FP4` shard에서 만들었습니다. 진짜 BF16/FP16 원본 expert를 가져온 것은 아닙니다. 따라서 “고정밀 원본 weight 기반”의 최종판으로 보기에는 아직 한 단계 부족합니다. + +또한 sidecar는 L10만 대상으로 했습니다. L10이 현재 가장 논리적인 승부처인 것은 맞지만, L10 하나로 Layer10Q4 전체 모델의 KMMLU/Think MAX 성능을 완전히 재현하지는 못했습니다. + +이번 평가는 prompt 수가 꽤 늘었지만, 여전히 내부 자동 채점 규칙에 의존합니다. exact-copy와 장문 지시문은 특히 채점 함수의 엄격도에 영향을 받습니다. + +마지막으로 한국어 특이 expert와 범용 reasoning expert를 완전히 분리하지는 못했습니다. full256의 KMMLU 강세와 Korean100 약세가 갈린 것을 보면, L10 내부에도 task별로 다른 expert subset이 필요한 상태입니다. + +## 11. 다음 액션 + +다음 단계는 `sidecar policy routing`입니다. 하나의 sidecar만 고정하는 대신, runtime에서 task mode별로 L10 sidecar coverage를 선택하게 만드는 편이 지금 데이터와 가장 잘 맞습니다. + +추천 구현 순서: + +1. CLI 옵션 추가: `--bitlift-policy chat|think|long|qa` +2. 정책별 sidecar 선택: + - `chat`: sidecar off 또는 LateStable5Q4 + - `think`: L10 top128 + - `long`: L10 top64 + - `qa`: L10 top128 또는 full256 +3. 동일 prompt set에서 policy switching benchmark 실행 +4. BF16/FP16 원본 shard 확보 시 L10 top64/top128/full256을 같은 manifest로 재생성 +5. Layer10Q4 전체 모델과 source sidecar 간 차이가 나는 expert/tensor를 diff 추적 + +## 12. 산출물 + +주요 산출물: + +```text +runs/20260520_l10_wide_source/ +reports/ds4_l10_wide_source_sidecar_20260520.md +``` + +중요 결과 파일: + +```text +runs/20260520_l10_wide_source/thinkmax30_l10_wide_source/thinkmax_l10_wide_hf_fp4_summary.json +runs/20260520_l10_wide_source/kmmlu100_l10_wide_source/summary.json +runs/20260520_l10_wide_source/kmmlu300_l10_wide_source/summary.json +runs/20260520_l10_wide_source/project_eval_l10_wide_source/summary.json +runs/20260520_l10_wide_source/longv2_l10_wide_source/summary.json +``` + +sidecar 파일: + +```text +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop64-L10-HF-FP4.sidecar.gguf +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop128-L10-HF-FP4.sidecar.gguf +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-Full256-L10-HF-FP4.sidecar.gguf +``` diff --git a/reports/ds4_latestable5q4_final_report_20260518.md b/reports/ds4_latestable5q4_final_report_20260518.md new file mode 100644 index 00000000..a8b47917 --- /dev/null +++ b/reports/ds4_latestable5q4_final_report_20260518.md @@ -0,0 +1,421 @@ +# DS4 Korean Bit-Lift LateStable5Q4 결과 보고 + +작성일: 2026-05-18 +작업 디렉터리: `/Users/kch3dri4n/llm_provide/ds4` + +## 1. 이번 작업의 결론 + +`KR-LateStable5Q4` GGUF 생성은 완료됐고, 런타임 로딩 문제까지 수정해 실제 추론, batch 평가, Think MAX 벤치, expert usage trace까지 수행했습니다. + +다만 모델 품질 관점에서는 `LateStable5Q4`를 바로 본 모델로 승격하기는 이릅니다. `Worst5Q4`보다는 한국어 long instruction에서 확실히 나아졌지만, 전체 held-out 한국어 점수는 base보다 낮습니다. + +핵심 판단은 다음과 같습니다. + +- 생성 성공: `DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-LateStable5Q4-chat-v2.gguf` +- Q4 적용 layer: `L37`, `L38`, `L40`, `L41`, `L42` +- 파일 크기: `95,779,807,840 bytes`, Finder 기준 약 `89G`, 약 `89.20 GiB` +- 한국어 100개 평가: base `88/100`, Worst5Q4 `80/100`, LateStable5Q4 `84/100` +- 영어/중국어/control 평가: 세 모델 모두 `60/60` +- exact/long extra: 세 모델 모두 `10/30` +- Think MAX: 속도는 정상, LateStable5Q4 자동 점수는 `2/10`으로 base/Worst의 `4/10`보다 낮음 +- expert trace: Q4로 올린 late layer들은 실제로 넓게 활성화됨. think trace에서는 stable core 5개 모두 선택됨. + +최종적으로는 `LateStable5Q4`는 “실험적으로 의미 있는 후보”이지만, 현재 데이터 기준 추천 순위는 아직 `base > LateStable5Q4 > Worst5Q4`입니다. + +## 2. 생성한 GGUF + +생성 파일: + +```text +/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-LateStable5Q4-chat-v2.gguf +``` + +크기 비교: + +```text +base 86,720,111,488 bytes +Worst5Q4 95,779,807,840 bytes +LateStable5Q4 95,779,807,840 bytes +``` + +`LateStable5Q4`는 base 대비 약 `+8.44 GiB`입니다. `Worst5Q4`와 같은 수의 routed expert layer 5개를 Q4_K로 올렸기 때문에 총 크기는 동일합니다. + +## 3. Q4 적용 검증 + +검증 결과: + +```text +LateStable5Q4 +L23: gate=iq2_xxs down=q2_K up=iq2_xxs +L25: gate=iq2_xxs down=q2_K up=iq2_xxs +L28: gate=iq2_xxs down=q2_K up=iq2_xxs +L34: gate=iq2_xxs down=q2_K up=iq2_xxs +L36: gate=iq2_xxs down=q2_K up=iq2_xxs +L37: gate=q4_K down=q4_K up=q4_K +L38: gate=q4_K down=q4_K up=q4_K +L40: gate=q4_K down=q4_K up=q4_K +L41: gate=q4_K down=q4_K up=q4_K +L42: gate=q4_K down=q4_K up=q4_K +``` + +즉 기존 `Worst5Q4`의 `L23/L25/L28/L34/L36`과는 겹치지 않고, 이번 후보는 late layer stable 후보 5개만 Q4_K로 올라갔습니다. + +## 4. 구현 변경 사항 + +### 4.1 partial HF shard 기반 생성 + +기존 `deepseek4-quantize`는 전체 GGUF를 만들 때 모든 tensor를 원본 HF safetensors에서 다시 생성하는 구조였습니다. 원본 DeepSeek-V4-Flash 전체 safetensors는 약 `159.6GB`라 로컬 디스크와 반복 실험에 부담이 컸습니다. + +이번에는 quantizer에 `--copy-unchanged` 옵션을 추가했습니다. + +동작 방식: + +- type이 바뀌지 않는 tensor는 기존 base GGUF에서 그대로 스트리밍 복사 +- Q4_K로 바뀌는 tensor만 원본 HF shard에서 재생성 +- 따라서 이번 생성에는 late 5개 layer에 해당하는 원본 shard 5개만 필요 + +받은 원본 shard: + +```text +model-00039-of-00046.safetensors L37 +model-00040-of-00046.safetensors L38 +model-00042-of-00046.safetensors L40 +model-00043-of-00046.safetensors L41 +model-00044-of-00046.safetensors L42 +model.safetensors.index.json +``` + +현재 이 partial HF 원본은 외장 디스크로 이동했습니다. + +```text +/Volumes/Back_UP/hf-cache-offload/deepseek-ai/DeepSeek-V4-Flash-late5 +``` + +프로젝트 내부에는 symlink만 남겼습니다. + +```text +/Users/kch3dri4n/llm_provide/ds4/hf-partial/DeepSeek-V4-Flash-late5 +``` + +### 4.2 Metal runtime view overlap 수정 + +최초 `LateStable5Q4` smoke test에서 다음 에러가 발생했습니다. + +```text +ds4: Metal model range 79.89..81.01 GiB is not covered by mapped model views +``` + +원인: + +- 기존 Metal model view overlap 기준은 `704,643,072 bytes`, 즉 약 `672 MiB` +- Q4_K routed expert tensor 하나는 `1,152 MiB` +- `blk.42.ffn_up_exps.weight`가 view 경계를 걸치면서 하나의 Metal buffer view 안에 완전히 들어가지 못함 + +수정: + +```c +#define DS4_METAL_MODEL_MAX_TENSOR_BYTES (1152ull * 1024ull * 1024ull) +``` + +수정 후 smoke test: + +```text +LateStable5Q4 ctx=4096 +prefill: 68.15 t/s +generation: 32.84 t/s +output: 안녕하세요, 무엇을 도와드릴까요? +``` + +이후 batch 평가와 Think MAX에서도 런타임 에러 없이 동작했습니다. + +## 5. 구조 평가 결과 + +평가 디렉터리: + +```text +/tmp/ds4-ko-cal/structured_eval_latestable5q4 +``` + +평가 조건: + +- `--metal` +- `ctx=4096` +- `--nothink` +- `temperature=0` +- 동일 prompt 190개 +- 모델 3개: base, Worst5Q4, LateStable5Q4 + +### 5.1 suite별 요약 + +| suite | base | Worst5Q4 | LateStable5Q4 | +|---|---:|---:|---:| +| korean100 | 88/100 | 80/100 | 84/100 | +| control60 | 60/60 | 60/60 | 60/60 | +| exact_long_extra | 10/30 | 10/30 | 10/30 | + +### 5.2 한국어 100개 세부 + +| kind | base | Worst5Q4 | LateStable5Q4 | +|---|---:|---:|---:| +| daily | 16/20 | 20/20 | 16/20 | +| exact | 20/20 | 20/20 | 20/20 | +| long | 12/20 | 0/20 | 8/20 | +| summary | 20/20 | 20/20 | 20/20 | +| tech | 20/20 | 20/20 | 20/20 | + +해석: + +- `Worst5Q4`는 daily는 좋아졌지만 long instruction이 완전히 무너졌습니다. +- `LateStable5Q4`는 long instruction을 `0/20 -> 8/20`으로 회복했습니다. +- 그러나 daily는 base와 같은 `16/20`으로 내려갔고, base의 long `12/20`에는 못 미쳤습니다. +- summary, tech, exact-copy 기본 세트는 세 모델 모두 안정적입니다. + +### 5.3 control 퇴화 확인 + +| kind | base | Worst5Q4 | LateStable5Q4 | +|---|---:|---:|---:| +| chinese | 20/20 | 20/20 | 20/20 | +| english | 20/20 | 20/20 | 20/20 | +| control_exact | 20/20 | 20/20 | 20/20 | + +control suite에서는 퇴화가 보이지 않았습니다. + +### 5.4 exact/long extra + +| kind | base | Worst5Q4 | LateStable5Q4 | +|---|---:|---:|---:| +| exact | 0/20 | 0/20 | 0/20 | +| long | 10/10 | 10/10 | 10/10 | + +extra exact는 세 모델 모두 실패했습니다. 주로 한글 자모와 label 보존이 엄격한 exact-copy 문제입니다. 이건 bit-lift 후보 간 차이보다 현재 모델/프롬프트/디코딩 체계의 공통 약점으로 보는 게 맞습니다. + +## 6. 속도 결과 + +### 6.1 구조 평가 평균 속도 + +| suite | model | avg prefill t/s | avg generation t/s | +|---|---|---:|---:| +| korean100 | base | 122.22 | 32.58 | +| korean100 | Worst5Q4 | 121.41 | 31.66 | +| korean100 | LateStable5Q4 | 123.33 | 31.41 | +| control60 | base | 58.86 | 32.15 | +| control60 | Worst5Q4 | 58.81 | 32.05 | +| control60 | LateStable5Q4 | 59.46 | 31.79 | +| exact_long_extra | base | 135.98 | 31.64 | +| exact_long_extra | Worst5Q4 | 135.66 | 31.32 | +| exact_long_extra | LateStable5Q4 | 137.18 | 31.24 | + +속도 해석: + +- LateStable5Q4의 prefill은 base/Worst와 동급입니다. +- decode는 base보다 약 `3.6%` 낮고 Worst와 거의 비슷합니다. +- Q4_K late layer 5개 추가로 인한 속도 손실은 실사용상 크지 않습니다. + +## 7. Think MAX 결과 + +벤치 디렉터리: + +```text +/tmp/ds4-ko-cal/thinkmax_bench_latestable5q4_20260518 +``` + +조건: + +- `--think-max` +- `ctx=393216` +- 10 prompt +- base, Worst5Q4, LateStable5Q4 + +요약: + +| model | pass | avg prefill t/s | avg generation t/s | avg generated tokens | +|---|---:|---:|---:|---:| +| base | 4/10 | 138.21 | 31.35 | 261.9 | +| Worst5Q4 | 4/10 | 137.06 | 31.25 | 268.8 | +| LateStable5Q4 | 2/10 | 137.05 | 31.24 | 256.2 | + +suite별: + +```text +base: control 2/3, exact 0/2, korean 2/4, long 0/1 +Worst5Q4: control 2/3, exact 0/2, korean 2/4, long 0/1 +LateStable5Q4: control 2/3, exact 0/2, korean 0/4, long 0/1 +``` + +해석: + +- Think MAX 런타임은 세 모델 모두 정상 동작합니다. +- 속도도 거의 동일합니다. +- 다만 LateStable5Q4는 Think MAX 한국어 자동 점수에서 좋지 않았습니다. +- 이 scoring은 final answer extraction이 완벽하지 않아서 절대값보다 상대 신호로 봐야 합니다. +- 그래도 LateStable을 Think MAX 주력 후보로 바로 쓰기는 어렵습니다. + +## 8. Expert Usage Trace + +trace 디렉터리: + +```text +/tmp/ds4-ko-cal/expert_usage_latestable5q4 +``` + +대상 stable core: + +```text +L40:E037 +L41:E184 +L38:E021 +L37:E025 +L42:E032 +``` + +### 8.1 nothink decode64, 10 prompts + +조건: + +```text +dataset: rendered_prompts_nothink.txt +ctx=4096 +max_prompts=10 +decode_tokens=64 +prompt_tokens=15310 +decode_tokens_done=325 +routed expert observations=83850 +``` + +stable core counts: + +| expert | selected_count | count_share | weight_share | +|---|---:|---:|---:| +| L40:E037 | 1 | 0.000513 | 0.000423 | +| L41:E184 | 9 | 0.004615 | 0.003490 | +| L38:E021 | 0 | 0.000000 | 0.000000 | +| L37:E025 | 0 | 0.000000 | 0.000000 | +| L42:E032 | 32 | 0.016410 | 0.013919 | + +target layer active expert 수: + +```text +L37 active experts: 200 / 256 +L38 active experts: 193 / 256 +L40 active experts: 179 / 256 +L41 active experts: 200 / 256 +L42 active experts: 198 / 256 +``` + +### 8.2 think decode64, 10 prompts + +조건: + +```text +dataset: rendered_prompts_think.txt +ctx=4096 +max_prompts=10 +decode_tokens=64 +prompt_tokens=15539 +decode_tokens_done=258 +routed expert observations=66564 +``` + +stable core counts: + +| expert | selected_count | count_share | weight_share | +|---|---:|---:|---:| +| L40:E037 | 18 | 0.011628 | 0.009122 | +| L41:E184 | 9 | 0.005814 | 0.004125 | +| L38:E021 | 1 | 0.000646 | 0.000391 | +| L37:E025 | 1 | 0.000646 | 0.000412 | +| L42:E032 | 37 | 0.023902 | 0.020571 | + +target layer active expert 수: + +```text +L37 active experts: 170 / 256 +L38 active experts: 174 / 256 +L40 active experts: 164 / 256 +L41 active experts: 183 / 256 +L42 active experts: 174 / 256 +``` + +해석: + +- Q4로 올린 late layer들은 실제로 많은 expert가 선택됩니다. +- think trace에서는 stable core 5개가 모두 활성화됐습니다. +- nothink trace에서는 stable core 중 3개만 활성화됐습니다. +- 즉 “stable core가 항상 모든 짧은 샘플에서 뜬다”는 식으로 해석하면 안 됩니다. +- layer-level Q4는 expert-level sidecar보다 더 넓은 보호막을 주지만, 비용도 더 큽니다. + +## 9. 한계점 + +이번 결과의 한계는 분명합니다. + +1. Q4_K 변경 tensor에는 별도 imatrix를 적용하지 않았습니다. + 기존 base에서 복사된 tensor는 기존 quant 상태를 유지하지만, 새로 생성된 Q4_K tensor는 이번 partial HF 기반 quantizer에서 imatrix 없이 생성됐습니다. 다음 고품질 변환에서는 late layer 대상 imatrix를 새로 만들어 넣는 편이 더 정직합니다. + +2. 평가 점수는 휴리스틱입니다. + 특히 long instruction과 Think MAX는 형식 조건을 기계적으로 채점합니다. 실제 사람이 보면 일부 실패가 쓸 만할 수도 있고, 반대로 pass가 품질적으로 빈약할 수도 있습니다. + +3. Think MAX 출력은 scoring과 맞지 않습니다. + Think MAX는 reasoning output이 길어지기 때문에 final answer extraction이 중요합니다. 현재 평가는 “실제 reasoning 모드 품질”이라기보다 “이 scoring harness에서의 통과율”입니다. + +4. 한국어 특이 expert와 일반 고활성 expert가 아직 분리되지 않았습니다. + 이번 layer 후보는 한국어 trace 기반이지만, 영어/중국어/control 대비 특이성 비율까지 반영한 것은 아닙니다. + +5. 이번 후보는 layer-level lift입니다. + 사용자가 원한 방향대로 Mixed32 같은 expert-level lift는 피했습니다. 대신 layer 전체를 올렸기 때문에 특정 layer 안의 모든 expert가 비용을 먹습니다. + +## 10. 다음 의사결정 + +현재 결과로는 다음 순서를 권합니다. + +1. `Worst5Q4`는 단독 후보에서 내립니다. + daily는 좋아졌지만 long instruction이 `0/20`이라 너무 위험합니다. + +2. `LateStable5Q4`는 보관하되 본 후보로 승격하지 않습니다. + Worst보다 long이 나아졌고 control 퇴화가 없지만, base보다 한국어 총점과 Think MAX 점수가 낮습니다. + +3. 다음 실험은 layer-level을 유지한다면 `KR-Layer10Q4`가 가장 합리적입니다. + 즉 `L23/L25/L28/L34/L36`과 `L37/L38/L40/L41/L42`를 합친 10개 layer Q4_K입니다. Worst 쪽 daily 개선과 LateStable 쪽 long 회복이 같이 나타나는지 확인할 수 있습니다. + +4. `KR-Layer10Q4`도 같은 190 prompt와 Think MAX 10 prompt로 평가해야 합니다. + base를 이기지 못하면 더 큰 Q4 확장은 중단하고, expert-level sidecar 또는 imatrix 개선으로 돌아가는 편이 낫습니다. + +5. 최종 목표가 한국어 품질이면 다음 trace는 반드시 control 대비 비율로 뽑아야 합니다. + `ko_usage / en_usage`, `ko_usage / zh_usage`, `ko_usage / generic_code_usage`를 layer와 expert별로 계산해야 진짜 한국어 특화 후보를 고를 수 있습니다. + +## 11. 산출물 경로 + +GGUF: + +```text +/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-LateStable5Q4-chat-v2.gguf +``` + +구조 평가: + +```text +/tmp/ds4-ko-cal/structured_eval_latestable5q4/summary.json +/tmp/ds4-ko-cal/structured_eval_latestable5q4/scores.csv +/tmp/ds4-ko-cal/structured_eval_latestable5q4/raw_results.jsonl +``` + +Think MAX: + +```text +/tmp/ds4-ko-cal/thinkmax_bench_latestable5q4_20260518/thinkmax_latestable5q4_summary.json +/tmp/ds4-ko-cal/thinkmax_bench_latestable5q4_20260518/thinkmax_latestable5q4_scores.csv +/tmp/ds4-ko-cal/thinkmax_bench_latestable5q4_20260518/thinkmax_latestable5q4_raw_results.jsonl +``` + +Expert usage: + +```text +/tmp/ds4-ko-cal/expert_usage_latestable5q4/latestable5q4_decode64_nothink_10prompts.csv +/tmp/ds4-ko-cal/expert_usage_latestable5q4/latestable5q4_decode64_think_10prompts.csv +``` + +Partial original HF shards: + +```text +/Volumes/Back_UP/hf-cache-offload/deepseek-ai/DeepSeek-V4-Flash-late5 +``` + diff --git a/reports/ds4_layer10q4_eval_report_20260519.md b/reports/ds4_layer10q4_eval_report_20260519.md new file mode 100644 index 00000000..303a14ac --- /dev/null +++ b/reports/ds4_layer10q4_eval_report_20260519.md @@ -0,0 +1,328 @@ +# DS4 KR-Layer10Q4 생성 및 1차 평가 보고서 + +작성일: 2026-05-19 +작업 경로: `/Users/kch3dri4n/llm_provide/ds4` +대상 모델: `DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Layer10Q4-chat-v2.gguf` + +## 1. 결론 요약 + +이번 단계에서는 기존 `Worst5Q4`의 early Q4 layer 5개에 late stable layer 5개를 더해 총 10개 routed expert layer를 Q4로 올린 `KR-Layer10Q4` GGUF를 생성하고, KMMLU 샘플 100개, 한국어 held-out 100개, 영어/중국어/control 60개, exact-copy/장문 확대 세트, Think MAX 벤치, expert usage trace를 확인했습니다. + +최종 판단은 다음과 같습니다. + +- `KR-Layer10Q4`는 KMMLU 샘플 100개에서 `71/100`으로 `Worst5Q4`와 동률이며 base 대비 `+2`입니다. +- Think MAX 소규모 벤치에서는 `6/10`으로 현재 비교군 중 가장 좋고, 한국어 Think MAX subset은 `4/4`를 통과했습니다. +- 다만 일반 `nothink` 한국어 held-out 100개에서는 `83/100`으로 base `88/100`, `LateStable5Q4` `84/100`보다 낮습니다. +- 특히 한국어 장문 지시문 subset에서 `4/20`만 통과해 base `12/20`, `LateStable5Q4` `8/20`보다 약합니다. +- 영어/중국어/control 퇴화는 이번 측정에서는 보이지 않았습니다. 모든 모델이 `60/60`을 통과했습니다. +- prefill/decode 속도는 실사용 가능한 범위입니다. 다만 외장 디스크 cold start에서는 Metal residency가 약 188초 걸렸으므로 배포 위치는 신중하게 잡아야 합니다. + +따라서 `KR-Layer10Q4`는 "Think MAX 한국어 후보"로는 보관할 가치가 있지만, "일반 한국어 기본 모델"로 바로 승격하기에는 장문 지시문 회귀가 큽니다. + +## 2. 디스크 정리 및 저장소 배치 + +내부 프로젝트 디렉터리의 GGUF 파일들이 커져서 외장 디스크 저장소를 먼저 정리했습니다. + +- `/Volumes/Timemachine`: 약 `263GiB` 여유가 있었지만 Time Machine 보호로 쓰기 제한이 있어 작업 대상에서 제외했습니다. +- `/Volumes/Back_UP`: 기존에는 약 `56GiB` 여유였고, redownload 가능한 Hugging Face cache를 제거해 작업 공간을 확보했습니다. +- 제거한 cache: `/Volumes/Back_UP/hf-cache-offload/JANGQ-AI/DeepSeek-V4-Flash-JANGTQ-K` +- `KR-Layer10Q4` 생성 후 `/Volumes/Back_UP` 여유 공간은 약 `38GiB`입니다. + +GGUF 본체는 외장 디스크에 두고, 프로젝트 내부에는 symlink만 둔 상태입니다. + +```text +실제 파일: +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Layer10Q4-chat-v2.gguf + +프로젝트 symlink: +/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Layer10Q4-chat-v2.gguf +``` + +GGUF 크기: + +```text +104,839,504,480 bytes +약 97.65 GiB +Finder 표시 약 98G +``` + +## 3. 생성 방식 + +이번 `KR-Layer10Q4`는 다음 구조입니다. + +- 기존 `Worst5Q4` 유지: `L23`, `L25`, `L28`, `L34`, `L36` routed expert tensors Q4 +- 추가 `LateStable5Q4`: `L37`, `L38`, `L40`, `L41`, `L42` routed expert tensors Q4 +- 총 Q4 routed expert layer: 10개 +- 각 target layer에서 `ffn_gate_exps.weight`, `ffn_down_exps.weight`, `ffn_up_exps.weight`를 Q4_K로 구성 + +생성은 `Worst5Q4` GGUF를 template으로 사용하고, late 5개 layer만 HF partial shard에서 다시 양자화하는 방식으로 수행했습니다. 이 방식은 이미 만들어진 early Q4 layer를 재생성하지 않고 복사하기 때문에 시간과 디스크 사용량을 줄입니다. + +생성 명령의 핵심은 다음과 같습니다. + +```bash +gguf-tools/deepseek4-quantize \ + --hf hf-partial/DeepSeek-V4-Flash-late5 \ + --template gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf \ + --out /Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Layer10Q4-chat-v2.gguf \ + --copy-unchanged \ + --tensor-type blk.37/38/40/41/42 ffn_{gate,down,up}_exps.weight=q4_k \ + --threads 8 +``` + +## 4. Tensor Type 검증 + +간이 GGUF parser로 tensor type을 확인했습니다. + +| layer | gate | down | up | 판정 | +|---:|---|---|---|---| +| L23 | q4_K | q4_K | q4_K | OK | +| L25 | q4_K | q4_K | q4_K | OK | +| L28 | q4_K | q4_K | q4_K | OK | +| L34 | q4_K | q4_K | q4_K | OK | +| L36 | q4_K | q4_K | q4_K | OK | +| L37 | q4_K | q4_K | q4_K | OK | +| L38 | q4_K | q4_K | q4_K | OK | +| L40 | q4_K | q4_K | q4_K | OK | +| L41 | q4_K | q4_K | q4_K | OK | +| L42 | q4_K | q4_K | q4_K | OK | + +대조용 low-bit layer도 확인했습니다. + +| layer | gate | down | up | +|---:|---|---|---| +| L24 | iq2_xxs | q2_K | iq2_xxs | +| L27 | iq2_xxs | q2_K | iq2_xxs | +| L39 | iq2_xxs | q2_K | iq2_xxs | + +즉 의도한 10개 layer만 Q4로 올라갔고, 주변 layer가 실수로 바뀐 흔적은 없습니다. + +## 5. 기본 구동 및 속도 + +Smoke test prompt: + +```text +한국어로 한 문장으로 인사하세요. +``` + +출력: + +```text +안녕하세요, 무엇을 도와드릴까요? +``` + +속도: + +```text +prefill: 68.21 t/s +generation: 32.46 t/s +``` + +주의할 점은 cold start입니다. 외장 HFS 볼륨에서 처음 Metal residency를 만들 때 약 `188,162 ms`, 즉 약 `188초`가 걸렸습니다. 이후 같은 세션에서는 file cache 덕분에 로드 시간이 크게 줄었습니다. 따라서 이 GGUF를 실사용하려면 내부 SSD 배치 또는 warm cache 운용이 필요합니다. + +## 6. KMMLU 샘플 100개 평가 + +평가 데이터는 `HAERAE-HUB/KMMLU`에서 seed `20260519`로 100개를 샘플링한 세트입니다. +소스: https://huggingface.co/datasets/HAERAE-HUB/KMMLU + +| model | correct | accuracy | invalid | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:|---:| +| base | 69/100 | 0.69 | 0 | 152.91 | 31.13 | +| Worst5Q4 | 71/100 | 0.71 | 0 | 149.86 | 30.84 | +| LateStable5Q4 | 69/100 | 0.69 | 0 | 151.30 | 31.00 | +| Layer10Q4 | 71/100 | 0.71 | 1 | 147.85 | 31.40 | + +Pairwise 관점에서는 `Layer10Q4`가 base 대비 `+2` net입니다. + +```text +Layer10Q4 vs base: +gains 8 +losses 6 +net +2 +``` + +KMMLU만 보면 `Layer10Q4`는 `Worst5Q4`와 동률입니다. 다만 `Layer10Q4`에서 invalid prediction이 1개 나온 점은 추가 샘플에서 다시 확인해야 합니다. + +## 7. 한국어 held-out 100개 및 control 평가 + +전체 suite 결과입니다. + +| suite | model | pass | pass rate | avg prefill t/s | avg decode t/s | +|---|---|---:|---:|---:|---:| +| korean100 | base | 88/100 | 0.88 | 122.22 | 32.58 | +| korean100 | Worst5Q4 | 80/100 | 0.80 | 121.41 | 31.66 | +| korean100 | LateStable5Q4 | 84/100 | 0.84 | 123.33 | 31.41 | +| korean100 | Layer10Q4 | 83/100 | 0.83 | 119.81 | 31.88 | +| control60 | base | 60/60 | 1.00 | 58.86 | 32.15 | +| control60 | Worst5Q4 | 60/60 | 1.00 | 58.81 | 32.05 | +| control60 | LateStable5Q4 | 60/60 | 1.00 | 59.46 | 31.79 | +| control60 | Layer10Q4 | 60/60 | 1.00 | 58.17 | 32.36 | +| exact_long_extra | base | 10/30 | 0.33 | 135.98 | 31.64 | +| exact_long_extra | Worst5Q4 | 10/30 | 0.33 | 135.66 | 31.32 | +| exact_long_extra | LateStable5Q4 | 10/30 | 0.33 | 137.18 | 31.24 | +| exact_long_extra | Layer10Q4 | 10/30 | 0.33 | 132.56 | 31.84 | + +`korean100`의 kind별 결과입니다. + +| kind | base | Worst5Q4 | LateStable5Q4 | Layer10Q4 | +|---|---:|---:|---:|---:| +| daily | 16/20 | 20/20 | 16/20 | 20/20 | +| exact | 20/20 | 20/20 | 20/20 | 19/20 | +| long | 12/20 | 0/20 | 8/20 | 4/20 | +| summary | 20/20 | 20/20 | 20/20 | 20/20 | +| tech | 20/20 | 20/20 | 20/20 | 20/20 | + +`control60`의 kind별 결과입니다. + +| kind | base | Worst5Q4 | LateStable5Q4 | Layer10Q4 | +|---|---:|---:|---:|---:| +| chinese | 20/20 | 20/20 | 20/20 | 20/20 | +| control_exact | 20/20 | 20/20 | 20/20 | 20/20 | +| english | 20/20 | 20/20 | 20/20 | 20/20 | + +`exact_long_extra`의 kind별 결과입니다. + +| kind | base | Worst5Q4 | LateStable5Q4 | Layer10Q4 | +|---|---:|---:|---:|---:| +| exact | 0/20 | 0/20 | 0/20 | 0/20 | +| long | 10/10 | 10/10 | 10/10 | 10/10 | + +해석: + +- 영어/중국어/control 퇴화는 발견되지 않았습니다. +- `Layer10Q4`는 일상 대화형 한국어에서는 강합니다. `daily 20/20`입니다. +- 그러나 장문 지시문에서 약합니다. `long 4/20`이라 base와 LateStable보다 낮습니다. +- `Worst5Q4`가 `long 0/20`으로 가장 크게 망가졌고, `Layer10Q4`는 그보다는 낫지만 아직 충분하지 않습니다. +- 장문 지시문 회귀는 layer-level Q4 확대가 단순히 품질을 올리는 방향으로만 작동하지 않는다는 신호입니다. + +## 8. Think MAX 벤치 + +`--ctx 393216` 조건에서 `Layer10Q4`만 새로 측정했고, 이전 Think MAX 결과와 비교했습니다. + +| model | pass | pass rate | avg prefill t/s | avg decode t/s | avg gen tokens | +|---|---:|---:|---:|---:|---:| +| base | 4/10 | 0.40 | 기존 측정 | 기존 측정 | 기존 측정 | +| Worst5Q4 | 4/10 | 0.40 | 기존 측정 | 기존 측정 | 기존 측정 | +| LateStable5Q4 | 2/10 | 0.20 | 기존 측정 | 기존 측정 | 기존 측정 | +| Layer10Q4 | 6/10 | 0.60 | 131.51 | 32.03 | 268.10 | + +`Layer10Q4` 세부 결과: + +| suite | pass | +|---|---:| +| korean | 4/4 | +| control | 2/3 | +| exact | 0/2 | +| long | 0/1 | + +해석: + +- Think MAX 조건에서는 `Layer10Q4`가 현재 후보 중 가장 좋습니다. +- 특히 한국어 Think MAX subset은 `4/4`를 통과했습니다. +- 하지만 exact-copy와 long은 여전히 약합니다. +- Think MAX용 모델로는 추가 검증할 가치가 있지만, exact/long 회귀를 해결하지 못한 상태입니다. + +## 9. Expert Usage Trace + +`Layer10Q4`에 대해 nothink decode 64, think decode 64 조건으로 10개 prompt씩 expert usage trace를 기록했습니다. + +Trace 요약: + +```text +nothink: +prompts=10 +prompt_tokens=15310 +decode_tokens=242 +routes=62436 + +think: +prompts=10 +prompt_tokens=15539 +decode_tokens=208 +routes=53664 +``` + +Q4 target layer별 활성 expert 수입니다. + +| layer | nothink active | think active | +|---:|---:|---:| +| L23 | 176/256 | 172/256 | +| L25 | 172/256 | 175/256 | +| L28 | 151/256 | 167/256 | +| L34 | 150/256 | 177/256 | +| L36 | 147/256 | 168/256 | +| L37 | 167/256 | 174/256 | +| L38 | 167/256 | 174/256 | +| L40 | 139/256 | 155/256 | +| L41 | 166/256 | 190/256 | +| L42 | 170/256 | 182/256 | + +각 target layer의 route 수: + +```text +nothink: 각 target layer 1452 routes +think: 각 target layer 1248 routes +``` + +즉 "우리가 Q4로 올린 layer들이 실제로 사용되는가?"라는 질문에 대해서는 yes입니다. 10 prompt만으로도 각 Q4 layer에서 139개에서 190개의 expert가 활성화되었습니다. + +다만 "이전에 안정 후보로 찍은 특정 expert가 항상 활성화되는가?"라는 질문에는 no입니다. 예를 들어 stable core였던 `L40:E037`, `L41:E184`, `L38:E021`, `L37:E025`, `L42:E032`는 이번 10 prompt trace에서 일부만 약하게 나타나거나 아예 나타나지 않는 경우가 있었습니다. + +이것은 두 가지를 의미합니다. + +- layer-level Q4는 넓은 route coverage를 제공하므로 runtime에서 확실히 사용됩니다. +- 하지만 한국어 품질을 정밀하게 올리려면 layer 전체 Q4보다 expert-level sidecar가 더 낫습니다. 특정 hot expert만 올리는 설계가 더 직접적입니다. + +## 10. 속도 및 실사용성 + +속도 측면에서는 큰 문제는 없습니다. + +- KMMLU decode 평균: `31.40 t/s` +- structured eval decode 평균: `31.88-32.36 t/s` +- Think MAX decode 평균: `32.03 t/s` +- prefill은 suite에 따라 다르지만, `Layer10Q4`가 base보다 약간 낮은 경향이 있습니다. + +다만 외장 디스크 cold start는 실사용에서 눈에 띄는 문제입니다. + +- 첫 Metal residency: 약 `188초` +- 이후 warm cache: 크게 완화됨 +- 배포 권장: 내부 SSD 또는 외장 SSD warm-cache 운용 + +## 11. 한계점 + +이번 실험의 한계는 명확합니다. + +1. KMMLU는 100개 샘플만 사용했습니다. `+2` net은 방향성 신호로 볼 수 있지만 통계적으로 확정하기에는 부족합니다. +2. Think MAX 벤치도 10 prompt뿐입니다. `Layer10Q4`가 좋아 보이지만 더 큰 세트로 확인해야 합니다. +3. `korean100`의 pass 기준은 자동 휴리스틱입니다. 실제 선호도, 자연스러움, 논리성 평가는 별도 judge 또는 사람 평가가 필요합니다. +4. exact-copy 확대 세트가 전 모델에서 `0/20`이라 모델 간 차이를 잘 분리하지 못했습니다. 난이도 구간을 나누어야 합니다. +5. layer-level Q4는 너무 넓은 처방입니다. 실제 hot expert만 올리는 설계보다 메모리 효율이 낮고, 장문 지시문 회귀를 일으킬 수 있습니다. +6. 이번에는 영어/중국어/control 60개만 봤습니다. 다국어 퇴화 검증은 최소 200개 이상으로 늘려야 합니다. +7. 외장 디스크 cold start가 커서, 벤치 속도와 실제 첫 실행 체감이 다릅니다. + +## 12. 다음 권장 진행 + +현재 기준으로는 다음 순서가 가장 합리적입니다. + +1. `Layer10Q4`는 Think MAX 후보로 보관합니다. +2. 일반 한국어 기본 후보는 `LateStable5Q4`와 base를 계속 기준선으로 둡니다. +3. `Layer10Q4`를 기본값으로 승격하지 않습니다. 장문 지시문 `4/20`이 너무 약합니다. +4. KMMLU를 `3 x 100` 또는 `1 x 300`으로 늘려서 `Worst5Q4`와 `Layer10Q4`의 동률이 재현되는지 확인합니다. +5. Think MAX 한국어 prompt를 30개 이상으로 늘려 `Layer10Q4`의 `4/4` 신호가 유지되는지 확인합니다. +6. exact-copy는 쉬움/중간/어려움으로 나누어 새 benchmark를 만듭니다. +7. 다음 큰 구현은 expert-level sidecar GGUF/runtime입니다. layer 전체 Q4보다 hot expert만 올리는 쪽이 메모리 대비 효과가 더 좋을 가능성이 큽니다. + +## 13. 최종 판정 + +`KR-Layer10Q4`는 생성 자체는 성공했고, target Q4 layer도 모두 의도대로 반영되었습니다. Expert usage trace에서도 Q4 layer들이 실제 decode와 think 경로에서 폭넓게 활성화되는 것을 확인했습니다. + +성능 면에서는 KMMLU와 Think MAX 한국어에서 좋은 신호가 있지만, 일반 한국어 장문 지시문에서 회귀가 큽니다. 따라서 지금 당장 기본 모델로는 `Layer10Q4`보다 더 섬세한 expert-level 접근이 필요합니다. + +현재 추천 운영안: + +```text +일반 nothink/chat: base 또는 LateStable5Q4 기준 유지 +Think MAX 한국어 실험: Layer10Q4 후보 유지 +다음 구현: expert-level sidecar GGUF/runtime +다음 평가: KMMLU 300개 + Think MAX 30개 + 장문 지시문 재설계 +``` + diff --git a/reports/ds4_mixed32_sidecar_from_base_20260520.md b/reports/ds4_mixed32_sidecar_from_base_20260520.md new file mode 100644 index 00000000..72140777 --- /dev/null +++ b/reports/ds4_mixed32_sidecar_from_base_20260520.md @@ -0,0 +1,368 @@ +# DS4 KR-Mixed32 Sidecar GGUF 구현 및 평가 보고서 + +작성일: 2026-05-20 +작업 루트: `/Users/kch3dri4n/llm_provide/ds4` + +## 1. 요약 결론 + +이번 작업에서는 `KR-Mixed32` expert-level sidecar GGUF를 실제로 생성하고, 기존 DS4 Metal runtime에서 sidecar expert가 선택되어 계산 경로에 들어가는 것까지 확인했습니다. 즉, “sidecar GGUF를 안전하게 읽는 설계 문서” 단계가 아니라, 실제 `./ds4 --bitlift-sidecar ...` 실행에서 38개 sidecar layer가 모두 route hit를 받는 단계까지 도달했습니다. + +하지만 품질 평가는 명확히 부정적입니다. 현재 만든 `Mixed32 sidecar`는 고정밀 원본 weight에서 2bit expert를 4bit로 올린 것이 아니라, 이미 양자화된 base GGUF의 `IQ2_XXS/Q2_K` expert slice를 dequantize한 뒤 `Q4_K`로 다시 저장한 것입니다. 따라서 손실된 정보가 복구되지 않으며, 실제 평가에서도 `base` 및 `Layer10Q4`보다 한국어, KMMLU, exact-copy, 장문 지시문 안정성이 크게 낮았습니다. + +최종 권장 사항은 다음과 같습니다. + +- 일반 chat/nothink: `base` 또는 기존 `LateStable5Q4` 계열 유지 +- Think MAX 한국어 실험: `Layer10Q4` 후보 유지 +- 이번 `KR-Mixed32-from-base.sidecar.gguf`: runtime 검증용/실험 아티팩트로 보존, 기본 사용 비추천 +- 다음 실제 bit-lift: base GGUF가 아니라 원본 BF16/FP16 또는 원래 JANG/MXTQ 변환 전 고정밀 expert weight에서 sidecar를 생성해야 함 + +## 2. 생성된 주요 산출물 + +### Full Mixed32 sidecar GGUF + +- 실제 파일: `/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-Mixed32-from-base.sidecar.gguf` +- 작업 디렉터리 symlink: `/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-KR-Mixed32-from-base.sidecar.gguf` +- 크기: `17,213,440,736 bytes`, 약 `16.03 GiB` +- base GGUF symlink 대상 크기: `86,720,111,488 bytes`, 약 `80.78 GiB` +- base + sidecar 합산: 약 `96.81 GiB` + +### 생성 요약 + +- sidecar 대상 layer 수: `38` +- layer 목록: `0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,24,26,27,29,30,31,32,33,35,37,38,39,40,41,42` +- expert slot 수: `1216` = `38 layers * 32 experts/layer` +- sidecar tensor 수: `152` +- tensor 구성: 각 layer마다 `gate/up/down/ids` +- source type id: `16 = IQ2_XXS`, `10 = Q2_K` +- output type: `Q4_K` +- source payload: 약 `8.01 GiB` +- Q4 sidecar payload: 약 `16.03 GiB` + +관련 파일: + +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/mixed32_from_base_fixed.summary.json` +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/mixed32_from_base_fixed.build.log` + +## 3. 구현 내용 + +### 새로 추가한 writer + +파일: `/Users/kch3dri4n/llm_provide/ds4/tools/write_bitlift_sidecar_from_base_gguf.py` + +기능: + +- base GGUF에서 selected expert slice를 직접 읽음 +- `Q2_K`, `IQ2_XXS`, `Q4_K`, `F16`, `BF16`, `F32` 입력을 처리할 수 있도록 quants dylib 호출 +- source expert slice를 chunk 단위로 dequantize +- selected expert를 `Q4_K`로 quantize +- sidecar GGUF에 `blk.L.ffn_*_exps.bitlift_q4.weight` 및 expert id tensor 기록 +- 대형 모델 전체를 메모리에 올리지 않고 `row_chunk=128` 단위로 처리 + +### quants dequantize API 보강 + +수정 파일: + +- `/Users/kch3dri4n/llm_provide/ds4/gguf-tools/quants.h` +- `/Users/kch3dri4n/llm_provide/ds4/gguf-tools/quants.c` + +추가 API: + +```c +bool ds4q_can_dequantize(ds4q_type type); +size_t ds4q_dequantize_chunk(ds4q_type type, const void *src, float *dst, + int64_t nrows, int64_t ncols); +``` + +지원한 dequantize type: + +- `F32` +- `F16` +- `BF16` +- `Q2_K` +- `Q4_K` +- `IQ2_XXS` + +### 중요한 버그 수정 + +초기 smoke sidecar에서 한국어 출력이 중국어/깨진 토큰으로 흔들렸고, 원인은 `IQ2_XXS` dequantize 구현이었습니다. + +문제: + +- `IQ2_XXS` grid raw 값 `1/3/5`를 그대로 magnitude로 사용함 +- 실제 runtime dot-product 계열은 grid 값에 대해 magnitude table `8/25/43`을 사용함 +- 이 차이 때문에 dequantize 후 Q4_K 재양자화가 원래 값을 심하게 왜곡함 + +수정: + +- `grid[j] == 1 -> 8` +- `grid[j] == 3 -> 25` +- `grid[j] == 5 -> 43` + +검증: + +- 수정 전 IQ2 roundtrip MSE: 약 `0.388` +- 수정 후 IQ2 roundtrip MSE: 약 `0.062` + +수정 후 smoke 출력: + +```text +개인정보 보호에서 가장 중요한 원칙은 정보주체의 동의와 자기결정권입니다. +``` + +수정 후 full sidecar sanity 출력: + +```text +개인정보 보호에서 가장 중요한 원칙은 **데이터 최소화**입니다. 필요한 정보만 수집하고 보관해야 합니다. +``` + +## 4. Runtime 활성화 검증 + +실행 방식: + +```bash +./ds4 -m ds4flash.gguf \ + --bitlift-sidecar gguf/DeepSeek-V4-Flash-KR-Mixed32-from-base.sidecar.gguf \ + --nothink -n 48 --temp 0 \ + -p '한국어로 두 문장 이내로 답하세요. 개인정보 보호에서 가장 중요한 원칙은 무엇인가요?' +``` + +runtime 로그: + +- sidecar loaded layers: `38` +- mapped sidecar size: 약 `16416.03 MiB` +- sanity prefill: `60.09 tok/s` +- sanity generation: `26.47 tok/s` + +Route trace 요약: + +- sidecar layer count: `38` +- 모든 sidecar layer에서 실제 sidecar route hit 발생 +- 평균 sidecar top-k share: `0.651` +- 최소/최대 sidecar top-k share: `0.205 / 0.779` +- 평균 row hit rate: `0.961` +- 최소/최대 row hit rate: `0.674 / 1.000` +- layer당 hit된 unique sidecar slot 평균: `23.9` + +의미: + +- GGUF가 단순히 load만 된 것이 아니라, routed-MoE top-k dispatch에서 sidecar expert가 실제로 선택됨 +- expert-level sidecar runtime 경로 자체는 기능적으로 작동함 +- 품질 문제는 sidecar dispatch 미작동 때문이 아니라, sidecar weight 품질/후보 선택/업퀀트 한계 문제로 보는 것이 맞음 + +관련 파일: + +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/mixed32_fixed_trace_summary.json` +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/mixed32_fixed_trace_stderr.log` +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/mixed32_fixed_trace_stdout.txt` + +## 5. 평가 결과 + +### 5.1 프로젝트 평가 190개 + +평가 구성: + +- held-out Korean 100 +- English/Chinese/control 60 +- exact-copy + long extra 30 + +결과: + +| suite | model | pass / n | pass rate | avg prefill tok/s | avg decode tok/s | +|---|---|---:|---:|---:|---:| +| korean100 | base | 88 / 100 | 0.880 | 124.26 | 31.69 | +| korean100 | mixed32_sidecar | 39 / 100 | 0.390 | 105.09 | 26.13 | +| control60 | base | 60 / 60 | 1.000 | 59.51 | 32.03 | +| control60 | mixed32_sidecar | 46 / 60 | 0.767 | 52.17 | 26.41 | +| exact_long_extra | base | 10 / 30 | 0.333 | 137.96 | 31.49 | +| exact_long_extra | mixed32_sidecar | 4 / 30 | 0.133 | 117.00 | 26.05 | + +속도 변화: + +- `mixed32_sidecar` prefill은 base 대비 약 `84.6~87.7%` +- `mixed32_sidecar` decode는 base 대비 약 `82.4~82.7%` +- sidecar 계산 경로는 정상 동작하지만, 16GiB 추가 매핑과 Q4 sidecar expert dispatch 때문에 decode 속도가 약 17~18% 낮아짐 + +관찰: + +- 일반 한국어도 pass rate가 `0.88 -> 0.39`로 크게 낮아짐 +- exact-copy와 한글 자모 복사에서 mojibake, 설명 끼어들기, 잘림, 안전 토큰 파편이 나타남 +- 영어/중국어/control에서도 `base`보다 명확히 퇴화 + +관련 파일: + +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/project_eval_base_vs_mixed32/summary.json` +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/project_eval_base_vs_mixed32/scores.csv` +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/project_eval_base_vs_mixed32/raw_results.jsonl` + +### 5.2 KMMLU 100개 + +평가 구성: + +- `HAERAE-HUB/KMMLU` +- deterministic local sample 100개 +- greedy, `--nothink` +- 답변 형식: `1~4` 숫자만 출력 + +결과: + +| model | correct / n | accuracy | invalid | avg prefill tok/s | avg decode tok/s | +|---|---:|---:|---:|---:|---:| +| base | 69 / 100 | 0.690 | 0 | 152.74 | 31.05 | +| mixed32_sidecar | 25 / 100 | 0.250 | 20 | 129.26 | 24.58 | + +관찰: + +- `mixed32_sidecar`는 accuracy가 `0.69 -> 0.25`로 하락 +- invalid prediction이 `20/100` 발생 +- 일부 출력에서 숫자 대신 설명 파편, `pp...` 같은 이상 토큰이 발생 +- 이 정도면 객관식 지식형에서도 실사용 후보가 아님 + +관련 파일: + +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/kmmlu100_base_vs_mixed32/summary.json` +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/kmmlu100_base_vs_mixed32/REPORT.md` +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/kmmlu100_base_vs_mixed32/scores.csv` + +### 5.3 Think MAX expanded30 + +평가 구성: + +- 30 prompt expanded set +- suites: Korean, long, exact, English/Chinese/control +- 모델: `base`, `Layer10Q4`, `mixed32_sidecar` +- mode: `--think-max` + +결과: + +| model | pass / n | pass rate | avg prefill tok/s | avg decode tok/s | Korean pass | Long pass | Control pass | Exact pass | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| base | 10 / 30 | 0.333 | 150.83 | 31.38 | 3 / 10 | 3 / 8 | 4 / 6 | 0 / 6 | +| Layer10Q4 | 17 / 30 | 0.567 | 150.76 | 31.06 | 8 / 10 | 5 / 8 | 4 / 6 | 0 / 6 | +| mixed32_sidecar | 10 / 30 | 0.333 | 129.28 | 25.98 | 5 / 10 | 2 / 8 | 3 / 6 | 0 / 6 | + +판단: + +- Think MAX에서는 `Layer10Q4`가 가장 좋은 후보 +- `mixed32_sidecar`는 base와 전체 pass rate가 같지만, 한국어/장문/컨트롤의 안정성이 Layer10Q4보다 낮음 +- 속도도 `Layer10Q4`는 base와 거의 같지만, `mixed32_sidecar`는 decode가 약 17% 느림 +- exact-copy는 세 모델 모두 취약하므로 별도 prompt/decoding/format-control 개선이 필요 + +관련 파일: + +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/thinkmax30_base_layer10_mixed32/thinkmax_full_sidecar_summary.json` +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/thinkmax30_base_layer10_mixed32/thinkmax_full_sidecar_scores.csv` +- `/Users/kch3dri4n/llm_provide/ds4/runs/20260520_full_sidecar_from_base/thinkmax30_base_layer10_mixed32/thinkmax_full_sidecar_raw_results.jsonl` + +## 6. 왜 Mixed32가 나빠졌는가 + +### 6.1 “업퀀트”는 bit-lift가 아님 + +이번 파일은 base GGUF에서 selected expert를 뽑았습니다. base GGUF의 routed expert 대부분은 이미 `IQ2_XXS` 또는 `Q2_K`입니다. + +따라서 절차는 다음과 같습니다. + +```text +이미 손실된 2bit 계열 expert +→ float로 dequantize +→ Q4_K로 다시 quantize +→ sidecar로 dispatch +``` + +이 과정은 저장 형식만 Q4가 될 뿐, 원래 4bit에 해당하는 정보를 복원하지 못합니다. 오히려 dequantize/requantize 과정과 runtime dispatch 차이 때문에 원래 base의 조정된 양자화 특성이 깨질 수 있습니다. + +### 6.2 top32/layer가 너무 넓음 + +38개 layer 전체에서 32개 expert/layer를 sidecar로 바꾼 것은 route trace 관점에서는 강하게 활성화되지만, 품질 관점에서는 영향 범위가 큽니다. trace에서는 평균 sidecar top-k share가 `65.1%`였으므로, 생성 중 상당수 routed expert 계산이 새 sidecar 값으로 대체됩니다. + +이 값이 “고정밀 개선 expert”라면 긍정적일 수 있지만, 이번에는 “base에서 재포장한 Q4 expert”이기 때문에 넓은 대체 범위가 오히려 품질 위험으로 작동했습니다. + +### 6.3 후보 선정은 한국어 routing이지 한국어 특이 expert가 아님 + +Mixed32 후보는 한국어 prefill/decode/think routing trace를 반영했습니다. 하지만 이것은 “한국어에서 자주 쓰인 expert”이지 “한국어만 개선하는 expert”가 아닙니다. + +정확히 분리하려면 다음 점수가 필요합니다. + +```text +ko_usage_score / control_usage_score +``` + +또는 layer별로 영어/중국어/control에서 함께 쓰이는 expert를 제외하는 penalty가 필요합니다. + +## 7. 현재 프로젝트 한계 + +- 원본 고정밀 weight가 없어서 진짜 bit-lift를 수행하지 못했습니다. +- sidecar writer는 base GGUF에서 재양자화하므로 품질 개선보다는 runtime 검증에 가깝습니다. +- exact-copy 평가는 세 모델 모두 취약하므로 모델만 바꿔 해결할 수 있는 문제가 아닐 수 있습니다. +- KMMLU 100개는 local regression signal이며 공개 벤치 수준의 대표성은 없습니다. +- Think MAX expanded30도 수가 작아 경향 확인용입니다. +- Back_UP 외장 디스크 여유가 약 `20GiB`뿐이라 추가 16GiB급 sidecar를 여러 개 만드는 것은 위험합니다. + +## 8. 다음 실행 계획 + +### P0. Mixed32-from-base는 보존하되 기본 후보에서 제외 + +현재 sidecar는 runtime proof artifact로 유지합니다. + +```text +/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-Mixed32-from-base.sidecar.gguf +``` + +기본 사용 후보로 올리지는 않습니다. + +### P1. Think MAX는 Layer10Q4를 우선 후보로 유지 + +현재 Think MAX 30개 평가에서: + +```text +Layer10Q4: 17/30 pass +base: 10/30 pass +Mixed32: 10/30 pass +``` + +속도도 Layer10Q4가 base와 거의 같으므로, Think MAX 한국어 실험은 계속 Layer10Q4 중심으로 가는 것이 맞습니다. + +### P2. 진짜 bit-lift sidecar는 고정밀 원본에서 다시 생성 + +필요한 입력: + +- BF16/FP16 expert tensors +- 또는 JANG/MXTQ 변환 전 중간 산출물 +- 또는 최소 Q4 이상 원본 expert checkpoint + +그 다음에야 다음 경로가 의미 있습니다. + +```text +고정밀 expert +→ 한국어/control differential trace로 후보 선정 +→ selected expert만 Q4_K/Q5_K sidecar +→ runtime dispatch +→ 한국어 + control + KMMLU + exact-copy 재평가 +``` + +### P3. 후보 수를 줄인 sidecar 실험 + +고정밀 원본을 구하기 전에는 큰 sidecar 대신 작은 실험만 권장합니다. + +- stable core 5~16 experts/layer +- late-layer only +- Layer10Q4처럼 특정 layer 단위 변형 +- exact-copy/control에서 자주 쓰이는 expert는 제외 + +### P4. 평가 체계 개선 + +다음 평가에서는 다음 세트를 고정해야 합니다. + +- KMMLU 300개 +- Think MAX 30개 +- 장문 지시문 v2 60개 +- exact-copy 강화 50개 +- 영어/중국어/control 각각 50개 +- route trace diff: Korean vs control + +## 9. 최종 판단 + +이번 작업의 기술적 성과는 분명합니다. expert-level sidecar GGUF writer와 runtime dispatch 검증이 되었고, route trace로 38개 sidecar layer가 모두 실제 활성화됨을 확인했습니다. + +하지만 `KR-Mixed32-from-base`는 품질 후보로는 탈락입니다. 현재 결과는 “한국어 expert 후보를 실제 GGUF sidecar로 만들 수 있다”는 엔지니어링 가능성을 입증했지만, “base GGUF에서 뽑아 Q4로 재포장하면 한국어 품질이 좋아진다”는 가설은 기각했습니다. + +다음 성공 가능성이 높은 방향은 `Layer10Q4`를 Think MAX 후보로 유지하면서, 진짜 고정밀 source weight 기반의 작은 sidecar부터 다시 bit-lift하는 것입니다. diff --git a/reports/ds4_sidecar_gpu_runtime_eval_20260519.md b/reports/ds4_sidecar_gpu_runtime_eval_20260519.md new file mode 100644 index 00000000..e317f053 --- /dev/null +++ b/reports/ds4_sidecar_gpu_runtime_eval_20260519.md @@ -0,0 +1,149 @@ +# DS4 Korean Bitlift Sidecar GPU Runtime Report + +- Date: 2026-05-19 KST +- Workspace: `/Users/kch3dri4n/llm_provide/ds4` +- Runtime target: base DS4 GGUF + expert-level Q4 sidecar GGUF +- Main conclusion: sidecar runtime is now real GPU-resident dispatch, not just loader/planning. Quality is mixed: useful for long/Think MAX style Korean instruction following, not good as a default nothink KMMLU replacement. + +## What Changed + +Implemented the missing runtime piece: selected MoE routes can now be partitioned on Metal into base routes and sidecar Q4 routes without CPU readback. + +- Added `kernel_dsv4_bitlift_partition_routes` in `metal/moe.metal`. +- Added `ds4_gpu_bitlift_partition_routes_tensor(...)` in `ds4_gpu.h` / `ds4_metal.m`. +- Wired decode and prefill routed-MoE helpers in `ds4.c` to call base MoE plus sidecar MoE and add both outputs. +- Kept `DS4_BITLIFT_TRACE_HITS=1` and added `DS4_BITLIFT_CPU_PARTITION=1` as diagnostic CPU/readback fallbacks. +- Extended local evaluation scripts with `thinktop32_sidecar` alias, which expands to `-m ds4flash.gguf --bitlift-sidecar gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf`. + +## Sidecar Artifact + +| item | value | +|---|---:| +| sidecar path | `/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf` | +| symlink | `/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf` | +| file size | 2.11 GiB | +| sidecar layers | L37, L38, L40, L41, L42 | +| expert slots | 160 total, 32 per layer | +| tensor triplets | 15 Q4 expert tensors + 5 id tensors | + +Important limitation: this sidecar was built from the existing Layer10Q4 source model, so only late layers already present as Q4 in that source could be extracted: L37, L38, L40, L41, L42. It is not the full Mixed32 38-layer plan yet. + +## Runtime Verification + +- `make ds4 ds4-bench ds4-eval` completed successfully. +- `./ds4 --inspect` loads base + sidecar and reports `bitlift sidecar: layers=5 expert_slots=160 tensor_triplets=15 qtype=q4_k`. +- Nothink smoke with sidecar completed at roughly `prefill 63.94 t/s`, `generation 31.45 t/s`. +- Think MAX smoke with `--ctx 393216` completed at roughly `prefill 108.52 t/s`, `generation 33.65 t/s`. +- Diagnostic trace fallback confirms all five sidecar layers receive sidecar routes on a Korean plan prompt. + +## Sidecar Route Activation Trace + +`DS4_BITLIFT_TRACE_HITS=1` was run on a short Korean structured prompt. This forces the diagnostic CPU partition path, so it is not a speed benchmark; it is only route coverage instrumentation. + +| layer | routed rows | rows with sidecar hit | sidecar routes | base routes | unique sidecar slots hit / 32 | +|---:|---:|---:|---:|---:|---:| +| L37 | 69 | 67 | 289 | 125 | 23 / 32 | +| L38 | 69 | 68 | 324 | 90 | 28 / 32 | +| L40 | 69 | 69 | 297 | 117 | 23 / 32 | +| L41 | 69 | 68 | 294 | 120 | 22 / 32 | +| L42 | 69 | 68 | 289 | 125 | 25 / 32 | + +Interpretation: every sidecar layer is active, and the prompt exercised 22 to 28 of the 32 slots per layer. This proves the route split/remap path is live. It does not prove every one of the 160 experts is activated on every workload; broader coverage requires a larger trace set. + +## KMMLU 300 + +Prompt mode: nothink, greedy, max 8 generated tokens. This is a regression signal, not a public benchmark number. + +| model | correct / n | accuracy | invalid | avg prefill t/s | avg decode t/s | +|---|---:|---:|---:|---:|---:| +| `base` | 209 / 300 | 69.7% | 0 | 154.04 | 31.38 | +| `layer10q4` | 212 / 300 | 70.7% | 1 | 150.81 | 30.95 | +| `thinktop32_sidecar` | 203 / 300 | 67.7% | 6 | 151.42 | 30.34 | + +Finding: `Layer10Q4` is best on this KMMLU sample. `thinktop32_sidecar` regresses by 2.0 points vs base and 3.0 points vs Layer10Q4, with more invalid one-token answers. Do not use this sidecar as the default nothink/KMMLU model. + +## Think MAX 30 + +Prompt mode: `--think-max --ctx 393216`, expanded 30-prompt suite. This is closer to the sidecar candidate’s intended use. + +| model | pass / n | pass rate | avg prefill t/s | avg decode t/s | avg generated tokens | Korean suite | long suite | control suite | exact suite | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| `base` | 10 / 30 | 33.3% | 151.65 | 31.36 | 304.57 | 3/10 | 3/8 | 4/6 | 0/6 | +| `layer10q4` | 17 / 30 | 56.7% | 148.44 | 31.14 | 319.70 | 8/10 | 5/8 | 4/6 | 0/6 | +| `thinktop32_sidecar` | 17 / 30 | 56.7% | 146.50 | 30.77 | 311.10 | 7/10 | 6/8 | 4/6 | 0/6 | + +Finding: sidecar ties Layer10Q4 overall at 17/30. It is slightly weaker on Korean short tasks, slightly stronger on long tasks, and about 1.2% slower in decode than Layer10Q4. + +## Long Instruction v2 + +Prompt mode: nothink, 60 Korean long-format prompts. This suite stresses formatting, risk/validation sections, bullet counts, step labels, and polite Korean. + +| model | pass / n | pass rate | avg score | avg prefill t/s | avg decode t/s | avg generated tokens | +|---|---:|---:|---:|---:|---:|---:| +| `base` | 42 / 60 | 70.0% | 0.852 | 166.64 | 31.53 | 348.73 | +| `layer10q4` | 27 / 60 | 45.0% | 0.820 | 164.97 | 31.28 | 369.45 | +| `thinktop32_sidecar` | 44 / 60 | 73.3% | 0.841 | 166.13 | 30.50 | 361.78 | + +By kind: + +| model | basic_plan | risk_plan | term_explain | validation_plan | +|---|---:|---:|---:|---:| +| `base` | 15/15 | 6/15 | 15/15 | 6/15 | +| `layer10q4` | 12/15 | 0/15 | 9/15 | 6/15 | +| `thinktop32_sidecar` | 13/15 | 6/15 | 13/15 | 12/15 | + +Finding: sidecar is strongest here: 44/60 vs base 42/60 and Layer10Q4 27/60. The biggest useful signal is `validation_plan`: sidecar 12/15, base 6/15, Layer10Q4 6/15. + +## Held-Out Korean 100 + Control 60 + Exact/Long Extra + +Prompt mode: nothink, greedy, local synthetic regression suites. + +| suite | model | pass / n | pass rate | avg prefill t/s | avg decode t/s | +|---|---|---:|---:|---:|---:| +| `korean100` | `base` | 88 / 100 | 88.0% | 124.51 | 31.64 | +| `korean100` | `layer10q4` | 84 / 100 | 84.0% | 122.80 | 31.32 | +| `korean100` | `thinktop32_sidecar` | 88 / 100 | 88.0% | 121.93 | 30.75 | +| `control60` | `base` | 60 / 60 | 100.0% | 59.76 | 32.02 | +| `control60` | `layer10q4` | 60 / 60 | 100.0% | 59.12 | 31.63 | +| `control60` | `thinktop32_sidecar` | 60 / 60 | 100.0% | 58.76 | 31.10 | +| `exact_long_extra` | `base` | 10 / 30 | 33.3% | 138.62 | 31.54 | +| `exact_long_extra` | `layer10q4` | 10 / 30 | 33.3% | 135.63 | 31.13 | +| `exact_long_extra` | `thinktop32_sidecar` | 10 / 30 | 33.3% | 135.45 | 30.60 | + +Finding: sidecar matches base on Korean held-out 100 (88/100) and all models pass control60 (60/60), so no obvious English/Chinese/control degradation is visible here. Exact-copy remains weak and unchanged at 10/30 for all three in the expanded exact/long extra suite. + +## Speed Summary + +- Sidecar overhead is small but measurable. In the broad held-out/control suite, decode is `30.75 t/s` vs base `31.64 t/s` on Korean100, about 2.8% slower. +- In Think MAX 30, sidecar decode is `30.77 t/s` vs Layer10Q4 `31.14 t/s`, about 1.2% slower. +- In long instruction v2, sidecar decode is `30.50 t/s` vs base `31.53 t/s`, about 3.3% slower. +- Prefill stayed comparable because the GPU route partition avoids CPU readback in the default path. + +## Decision + +Recommended operating split: + +- General chat / nothink: keep `base` or `LateStable5Q4` style baseline. Do not switch default to `thinktop32_sidecar`. +- KMMLU-like Korean multiple choice: `Layer10Q4` remains better in this local sample. +- Think MAX / long Korean planning: keep `thinktop32_sidecar` as a live experimental candidate; it ties Layer10Q4 on Think MAX 30 and beats base/Layer10Q4 on long instruction v2. +- Exact-copy: none of these variants solves the issue. This needs prompt/template work, decoding constraints, or targeted exact-copy calibration rather than this sidecar alone. + +## Remaining Limits + +- This is not full KR-Mixed32. The sidecar only contains five late layers because the source Q4 GGUF only had those Q4 expert slices available. +- The current GPU route partition computes both base and sidecar MoE for fixed six lanes with zero-weight inactive entries. That is simple and stable, but not the most efficient possible dispatch. +- Route activation trace is sampled. It proves the sidecar path is live, not exhaustive activation of every expert under all workloads. +- KMMLU and synthetic scoring are local regression signals. They are enough for engineering direction, not a final public quality claim. + +## Files + +- Runtime/eval output directory: `/Users/kch3dri4n/llm_provide/ds4/runs/20260519_sidecar_gpu_runtime` +- This report: `/Users/kch3dri4n/llm_provide/ds4/reports/ds4_sidecar_gpu_runtime_eval_20260519.md` +- Patch snapshot: `/Users/kch3dri4n/llm_provide/ds4/runs/20260519_sidecar_gpu_runtime/meta/sidecar_gpu_runtime.patch` +- Sidecar GGUF: `/Volumes/Back_UP/ds4-gguf-offload/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf` +- KMMLU report: `/Users/kch3dri4n/llm_provide/ds4/runs/20260519_sidecar_gpu_runtime/kmmlu300/REPORT.md` +- Long v2 report: `/Users/kch3dri4n/llm_provide/ds4/runs/20260519_sidecar_gpu_runtime/longv2/REPORT.md` + +## Next Engineering Step + +Build the real full Mixed32 sidecar from a source that actually has Q4 slices for all selected layers, or add an extraction path that creates Q4 sidecar tensors directly from base 2-bit experts. Then rerun the same four suites and compare against this five-layer ThinkTop32 sidecar baseline. diff --git a/reports/ds4_thinkmax_bench_20260518.md b/reports/ds4_thinkmax_bench_20260518.md new file mode 100644 index 00000000..8510395f --- /dev/null +++ b/reports/ds4_thinkmax_bench_20260518.md @@ -0,0 +1,225 @@ +# DS4 Think MAX 동작 확인 및 벤치 보고서 + +작성일: 2026-05-18 +작업 경로: `/Users/kch3dri4n/llm_provide/ds4` +결과 경로: `/tmp/ds4-ko-cal/thinkmax_bench_20260518` + +## 1. 결론 + +`--think-max`는 두 GGUF 모두에서 정상 동작했습니다. + +- Base GGUF: 정상 실행 +- Worst5Q4 GGUF: 정상 실행 +- `ctx=393216`에서는 실제 Think MAX 경로로 실행됨 +- `ctx=4096`에서는 의도대로 warning 후 normal thinking/high 경로로 downgrade됨 +- Metal graph backend, batch generation, expert usage trace 모두 정상 실행됨 +- 평균 decode 속도는 base와 Worst5Q4가 거의 동일함 + +다만 Think MAX는 현재 구현상 “Reasoning Effort: Absolute maximum...” prefix를 넣고 ``로 진입하는 방식입니다. 그래서 128~512 token 예산에서는 최종 답변보다 reasoning/planning 텍스트가 길게 생성되는 경우가 많았습니다. 즉, Think MAX는 동작과 속도는 양호하지만, exact-copy나 짧은 형식 준수 작업에는 nothink보다 부적합합니다. + +## 2. 확인한 모델 + +| 모델 | 경로 | 크기 | +|---|---:|---:| +| Base | `/Users/kch3dri4n/llm_provide/ds4/ds4flash.gguf` | 80.76 GiB | +| Worst5Q4 | `/Users/kch3dri4n/llm_provide/ds4/gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf` | 89.20 GiB | + +Worst5Q4는 L23, L25, L28, L34, L36 routed expert layer를 Q4로 올린 GGUF입니다. + +## 3. Think MAX 조건 + +코드상 Think MAX 최소 context는 다음 값입니다. + +```text +DS4_THINK_MAX_MIN_CONTEXT = 393216 +``` + +`--think-max --ctx 393216` 이상에서는 Think MAX prefix가 들어가고, 그보다 작으면 normal thinking으로 downgrade됩니다. + +컨텍스트 버퍼 추정치는 다음과 같습니다. + +| ctx | backend | context buffers | +|---:|---|---:| +| 4096 | Metal | 263.46 MiB | +| 393216 | Metal | 6889.71 MiB | +| 524288 | Metal | 9121.71 MiB | + +## 4. Smoke Test + +### 실제 Think MAX, ctx=393216 + +짧은 단일 prompt로 두 모델 모두 실행했습니다. + +| 모델 | context buffer | mapped model | prefill | decode | +|---|---:|---:|---:|---:| +| Base | 6889.71 MiB | 82697.67 MiB | 108.83 tok/s | 33.96 tok/s | +| Worst5Q4 | 6889.71 MiB | 91337.67 MiB | 110.17 tok/s | 33.63 tok/s | + +둘 다 downgrade warning 없이 실제 Think MAX 경로로 실행됐습니다. + +### 낮은 ctx에서 downgrade 확인, ctx=4096 + +`--think-max --ctx 4096`에서는 아래 warning이 출력되고 normal thinking으로 내려갔습니다. + +```text +ds4: warning: --think-max needs --ctx >= 393216; ctx=4096 uses normal thinking instead +``` + +| 모델 | context buffer | prefill | decode | +|---|---:|---:|---:| +| Base | 263.46 MiB | 79.77 tok/s | 33.40 tok/s | +| Worst5Q4 | 263.46 MiB | 79.64 tok/s | 33.35 tok/s | + +## 5. Think MAX 답변 예산 확인 + +Git 설명 prompt를 base에서 128 tokens와 256 tokens로 비교했습니다. + +- 128 tokens: reasoning이 대부분을 차지해서 최종 한국어 답변까지 도달하지 못함 +- 256 tokens: 최종 한국어 답변까지 도달함 + +따라서 Think MAX 평가는 최소 256 tokens 이상으로 잡아야 합니다. 긴 지시문이나 exact-copy류는 512~768 tokens에서도 reasoning이 길어지는 경우가 있어, 형식 준수 평가는 nothink와 분리해야 합니다. + +## 6. Batch Bench, 10 Prompts, ctx=393216 + +벤치 구성: + +- 한국어 일상 메시지 1개 +- 한국어 요약 1개 +- 한국어 Git 설명 1개 +- 한국어 prepared statement 설명 1개 +- 장문 지시문 1개 +- 영어 control 1개 +- 중국어 control 1개 +- format control exact 1개 +- 한국어 exact-copy 2개 + +토큰 예산: + +- 일반 prompt: 256 tokens +- 장문 prompt: 384 tokens + +### 전체 결과 + +| 모델 | n | pass | avg prefill | avg decode | avg generated | +|---|---:|---:|---:|---:|---:| +| Base | 10 | 4/10 | 137.94 tok/s | 31.47 tok/s | 261.9 | +| Worst5Q4 | 10 | 4/10 | 136.90 tok/s | 31.35 tok/s | 268.8 | + +### Suite별 결과 + +| 모델 | suite | n | pass | avg decode | +|---|---|---:|---:|---:| +| Base | Korean | 4 | 2 | 31.48 tok/s | +| Base | Control | 3 | 2 | 31.48 tok/s | +| Base | Exact | 2 | 0 | 31.47 tok/s | +| Base | Long | 1 | 0 | 31.40 tok/s | +| Worst5Q4 | Korean | 4 | 2 | 31.35 tok/s | +| Worst5Q4 | Control | 3 | 2 | 31.36 tok/s | +| Worst5Q4 | Exact | 2 | 0 | 31.34 tok/s | +| Worst5Q4 | Long | 1 | 0 | 31.31 tok/s | + +낮은 pass rate는 모델 실행 실패가 아니라 Think MAX의 출력 방식 때문입니다. 많은 실패 케이스에서 모델은 정답을 만들기 전에 지시 해석과 출력 계획을 길게 쓰다가 token budget이 끝났습니다. + +## 7. Extended 2x Bench, 실패 취약 6개 재측정 + +256-token 벤치에서 실패하기 쉬웠던 6개 prompt를 2배 토큰 예산으로 재실행했습니다. + +토큰 예산: + +- 일반/정확복사/control: 512 tokens +- 장문: 768 tokens + +| 모델 | n | pass | avg prefill | avg decode | avg generated | +|---|---:|---:|---:|---:|---:| +| Base | 6 | 0/6 | 141.62 tok/s | 31.35 tok/s | 554.7 | +| Worst5Q4 | 6 | 2/6 | 141.04 tok/s | 31.25 tok/s | 554.7 | + +이 결과는 “Worst5Q4가 더 좋다”라기보다, Think MAX에서 token budget과 final-answer delimiter가 없으면 자동 채점이 불안정하다는 의미가 큽니다. 특히 exact-copy와 format-copy는 마지막 줄에 답을 쓰라고 해도 reasoning이 계속 이어지는 경향이 있어 Think MAX 평가 과제로 적합하지 않았습니다. + +## 8. Expert Activation Check + +Think MAX 형태로 렌더링한 10개 prompt에 대해 decode 64 tokens routing trace를 생성했습니다. + +Trace 조건: + +- ctx: 393216 +- decode tokens: 64 per prompt +- prompts: 10 +- prompt tokens: 1550 +- decode tokens: 640 +- routed expert observations: 165120 per model + +확인 대상 stable experts: + +```text +L40:E037 +L41:E184 +L38:E021 +L37:E025 +L42:E032 +``` + +### Base에서의 활성화 + +| Expert | selected_count | count_share | weight_share | +|---|---:|---:|---:| +| L40:E037 | 454 | 11.82% | 16.49% | +| L41:E184 | 448 | 11.67% | 15.09% | +| L38:E021 | 442 | 11.51% | 18.72% | +| L37:E025 | 437 | 11.38% | 16.30% | +| L42:E032 | 334 | 8.70% | 12.88% | + +### Worst5Q4에서의 활성화 + +| Expert | selected_count | count_share | weight_share | +|---|---:|---:|---:| +| L40:E037 | 446 | 11.61% | 18.85% | +| L41:E184 | 440 | 11.46% | 14.66% | +| L38:E021 | 438 | 11.41% | 21.01% | +| L37:E025 | 435 | 11.33% | 17.93% | +| L42:E032 | 373 | 9.71% | 18.56% | + +결론: 이전에 지목한 stable experts는 Think MAX decode trace에서도 모두 강하게 활성화됐습니다. 특히 L40:E037, L41:E184, L38:E021, L37:E025는 두 모델 모두에서 global top권에 반복 등장했습니다. + +## 9. 속도 판단 + +Think MAX 실제 실행의 decode 속도는 약 31.2~34.0 tok/s 범위였습니다. + +- 짧은 smoke: 33.6~34.0 tok/s +- 10 prompt batch: 31.3~31.5 tok/s +- extended 2x batch: 31.25~31.35 tok/s + +Worst5Q4는 Base 대비 decode 기준 약 0.1~0.2 tok/s 낮은 수준이라, 현재 측정에서는 실사용상 큰 속도 차이로 보기 어렵습니다. prefill도 같은 프롬프트 기준으로 거의 같은 범위입니다. + +## 10. 한계와 해석 주의 + +- Think MAX는 reasoning을 길게 쓰므로, nothink와 같은 채점 기준을 그대로 적용하면 과소평가됩니다. +- exact-copy와 짧은 형식 준수는 Think MAX보다 nothink가 맞습니다. +- 이번 Think MAX bench는 10개 + 확장 6개로 작은 smoke/bench 성격입니다. +- 한국어 품질의 본평가는 held-out 100개 nothink 결과를 더 신뢰해야 합니다. +- Think MAX 품질을 제대로 보려면 final answer delimiter, 충분한 token budget, reasoning 제거 후 채점 파이프라인이 필요합니다. +- expert activation trace는 decode 64 token 샘플이므로, 장기 생성 전체 routing 분포를 대표한다고 단정하면 안 됩니다. + +## 11. 생성 산출물 + +주요 결과 파일: + +```text +/tmp/ds4-ko-cal/thinkmax_bench_20260518/thinkmax_summary.json +/tmp/ds4-ko-cal/thinkmax_bench_20260518/thinkmax_scores.csv +/tmp/ds4-ko-cal/thinkmax_bench_20260518/thinkmax_raw_results.jsonl +/tmp/ds4-ko-cal/thinkmax_bench_20260518/thinkmax_extended2x_summary.json +/tmp/ds4-ko-cal/thinkmax_bench_20260518/thinkmax_extended2x_scores.csv +/tmp/ds4-ko-cal/thinkmax_bench_20260518/expert_usage_thinkmax_base_decode64.csv +/tmp/ds4-ko-cal/thinkmax_bench_20260518/expert_usage_thinkmax_worst5q4_decode64.csv +/tmp/ds4-ko-cal/thinkmax_bench_20260518/expert_activation_thinkmax_decode64_summary.json +/tmp/ds4-ko-cal/thinkmax_bench_20260518/hot_experts_thinkmax_base_decode64_top5_by_layer.csv +/tmp/ds4-ko-cal/thinkmax_bench_20260518/hot_experts_thinkmax_worst5q4_decode64_top5_by_layer.csv +/Users/kch3dri4n/llm_provide/ds4/tools/bench_thinkmax_ds4.py +``` + +다운로드용 압축본: + +```text +/Users/kch3dri4n/Downloads/ds4_thinkmax_bench_20260518.zip +``` diff --git a/reports/hf_readme_l10_base_fp8_sidecar_20260521.md b/reports/hf_readme_l10_base_fp8_sidecar_20260521.md new file mode 100644 index 00000000..1b7bf2aa --- /dev/null +++ b/reports/hf_readme_l10_base_fp8_sidecar_20260521.md @@ -0,0 +1,87 @@ +--- +license: other +library_name: gguf +base_model: deepseek-ai/DeepSeek-V4-Flash-Base +tags: +- gguf +- deepseek-v4 +- moe +- sidecar +- korean +- experimental +private: true +--- + +# DeepSeek-V4-Flash KR L10 Base-FP8 Q4 Sidecar GGUF + +This private repository contains experimental Layer 10 routed-expert sidecar GGUF artifacts for local DS4 runtime experiments. + +## Important Result + +These artifacts are **not** the current recommended production variant. + +The source-based sidecar pipeline worked: the files load, inspect correctly, route selected experts through sidecar tensors, and run structured/KMMLU/Think MAX/long-instruction evaluations without runtime failures. However, quality did not beat the existing operational choices. + +Current recommendation from the local evaluation: + +- General chat / nothink: keep `base` or `LateStable5Q4`. +- Think MAX / KMMLU Korean experiments: keep `Layer10Q4`. +- This Base-FP8 L10 sidecar set: keep as reproducible experimental artifacts and pipeline proof. + +## Artifacts + +| file | coverage | size | sha256 | +|---|---:|---:|---| +| `DeepSeek-V4-Flash-KR-ThinkTop64-L10-BaseFP8-Q4.sidecar.gguf` | L10 top64 routed experts | 864M | `fb3c755c658e39287424dfe85cc9bfe0fbcc8a4bfbaf775ed0263e4640de2f0e` | +| `DeepSeek-V4-Flash-KR-ThinkTop128-L10-BaseFP8-Q4.sidecar.gguf` | L10 top128 routed experts | 1.7G | `74bc248b0ff480f4a56066a73693b15e8dcfdb8e0d5608119ada730f58db888b` | +| `DeepSeek-V4-Flash-KR-Full256-L10-BaseFP8-Q4.sidecar.gguf` | L10 all 256 routed experts | 3.4G | `b22a14ab2bedf72ef31968168186f8c937c229c3e2bd38b9fb55346b903ee94a` | + +## Source + +The source tensors were read from `deepseek-ai/DeepSeek-V4-Flash-Base`, specifically Layer 10 expert tensors in `model-00012-of-00046.safetensors`. + +The source format is official Base FP8: + +- weight dtype: `F8_E4M3` +- block scale dtype: `F32` +- block shape: 128 x 128 +- sidecar output quantization: `Q4_K` + +This is not a full BF16-source sidecar. + +## Local Evaluation Summary + +| eval | base | Layer10Q4 | Top64 BaseFP8 | Top128 BaseFP8 | Full256 BaseFP8 | +|---|---:|---:|---:|---:|---:| +| Korean structured | 88/100 | not rerun here | 85/100 | 85/100 | 80/100 | +| Control structured | 60/60 | not rerun here | 60/60 | 60/60 | 60/60 | +| Exact/long extra | 10/30 | not rerun here | 10/30 | 10/30 | 10/30 | +| KMMLU 300 | 209/300 | 212/300 | 205/300 | 205/300 | 205/300 | +| Think MAX 30 | 10/30 | 17/30 | 14/30 | 12/30 | 10/30 | +| Long instruction v2 | 41/60 | 27/60 | 34/60 | 30/60 | 36/60 | + +Decode speed stayed around 31 tok/s across the tested variants. The result is therefore a quality-selection issue, not a runtime-stability issue. + +## How To Load + +Use with a compatible local DS4 runtime that supports `--bitlift-sidecar`: + +```bash +./ds4 --metal \ + -m ds4flash.gguf \ + --bitlift-sidecar DeepSeek-V4-Flash-KR-ThinkTop64-L10-BaseFP8-Q4.sidecar.gguf \ + --nothink -p "한국어로 짧게 답하세요." +``` + +For Think MAX: + +```bash +./ds4 --metal \ + -m ds4flash.gguf \ + --bitlift-sidecar DeepSeek-V4-Flash-KR-ThinkTop64-L10-BaseFP8-Q4.sidecar.gguf \ + --think-max -c 393216 -p "CTF와 시스템 보안을 초보자에게 설명해 주세요." +``` + +## Included Reports + +The repository also includes local reports and summary JSON files under `reports/` and `eval/` for reproducibility. diff --git a/tools/bench_thinkmax_ds4.py b/tools/bench_thinkmax_ds4.py new file mode 100644 index 00000000..f97b5bda --- /dev/null +++ b/tools/bench_thinkmax_ds4.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +import argparse +import csv +import json +import re +import subprocess +import time +from pathlib import Path + + +ROOT = Path("/Users/kch3dri4n/llm_provide/ds4") +DEFAULT_OUT = Path("/tmp/ds4-ko-cal/thinkmax_bench_20260518") + +MODELS = { + "base": "ds4flash.gguf", + "worst5q4": "gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf", + "latestable5q4": "gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-LateStable5Q4-chat-v2.gguf", + "layer10q4": "gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Layer10Q4-chat-v2.gguf", + "thinktop32_sidecar": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf", + }, + "mixed32_sidecar": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-Mixed32-from-base.sidecar.gguf", + }, + "thinktop32_late5_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Late5-HF-FP4.sidecar.gguf", + }, + "thinktop32_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-L10-HF-FP4.sidecar.gguf", + }, + "thinktop64_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop64-L10-HF-FP4.sidecar.gguf", + }, + "thinktop128_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop128-L10-HF-FP4.sidecar.gguf", + }, + "full256_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-Full256-L10-HF-FP4.sidecar.gguf", + }, + "thinktop64_l10_base_fp8_q4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop64-L10-BaseFP8-Q4.sidecar.gguf", + }, + "thinktop128_l10_base_fp8_q4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop128-L10-BaseFP8-Q4.sidecar.gguf", + }, + "full256_l10_base_fp8_q4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-Full256-L10-BaseFP8-Q4.sidecar.gguf", + }, + "thinktop32_l8_l12_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-L8-L12-HF-FP4.sidecar.gguf", + }, +} + + +def model_cli_args(model_spec): + if isinstance(model_spec, dict): + return ["-m", model_spec["base"], "--bitlift-sidecar", model_spec["sidecar"]] + return ["-m", model_spec] + + +def hangul_ratio(text): + chars = [c for c in text if not c.isspace()] + if not chars: + return 0.0 + return sum(1 for c in chars if re.match(r"[가-힣ㄱ-ㅎㅏ-ㅣ]", c)) / len(chars) + + +def latin_ratio(text): + chars = [c for c in text if c.isalpha()] + if not chars: + return 0.0 + return sum(1 for c in chars if ("A" <= c <= "Z") or ("a" <= c <= "z")) / len(chars) + + +def cjk_ratio(text): + chars = [c for c in text if not c.isspace()] + if not chars: + return 0.0 + return sum(1 for c in chars if "\u4e00" <= c <= "\u9fff") / len(chars) + + +def sentence_count_ko(text): + return len([x for x in re.split(r"[.!?。!?\n]+", text.strip()) if x.strip()]) + + +def last_nonempty_line(text): + lines = [line.strip() for line in text.splitlines() if line.strip()] + return lines[-1] if lines else "" + + +def build_prompts(): + return [ + { + "id": "tm-ko-daily-001", + "suite": "korean", + "kind": "daily", + "n": 256, + "prompt": ( + "상황: 조별과제 팀원이 마감 하루 전까지 자료를 보내지 않았습니다. " + "한국어로 공손한 메시지를 3문장 이내로 작성하고, 마지막 문장은 오늘 밤 9시까지 자료를 보내 달라는 요청으로 끝내세요." + ), + }, + { + "id": "tm-ko-summary-001", + "suite": "korean", + "kind": "summary", + "n": 256, + "prompt": ( + "다음 글을 읽고 한국어로 답하세요.\n\n" + "[글: 데이터 편향]\n" + "인공지능 모델은 학습 데이터의 분포를 반영한다. 특정 집단이나 언어가 데이터에서 적게 나타나면 모델의 답변 품질도 낮아질 수 있다. " + "따라서 모델 평가에서는 전체 평균뿐 아니라 집단별 성능 차이를 따로 확인해야 한다.\n\n" + "작업: 제목을 붙이고 핵심 내용을 정확히 3문장으로 요약하세요. 글에 없는 내용을 추가하지 마세요." + ), + }, + { + "id": "tm-ko-tech-001", + "suite": "korean", + "kind": "tech", + "n": 256, + "keys": ["branch", "merge", "통합"], + "prompt": "Git에서 branch와 merge의 차이를 초보자에게 한국어로 5문장 이내로 설명해 주세요.", + }, + { + "id": "tm-ko-security-001", + "suite": "korean", + "kind": "tech", + "n": 256, + "keys": ["쿼리", "입력", "분리"], + "prompt": "SQL Injection을 방어하는 prepared statement의 원리를 공격 절차 없이 한국어로 설명해 주세요.", + }, + { + "id": "tm-ko-long-001", + "suite": "long", + "kind": "long", + "n": 384, + "prompt": ( + "다음 조건을 모두 만족하는 한국어 실행 계획을 작성하세요.\n" + "상황: 한국어 모델 평가 결과를 발표 자료로 정리해야 합니다.\n" + "조건: 첫 줄 제목은 15자 이내, 첫 문단은 2문장, 목표 bullet은 정확히 3개, " + "각 단계는 '단계 N:'으로 시작, 위험 요소 2개와 완화 방법 2개 포함, 표 사용 금지, " + "마지막 문장은 오늘 바로 할 수 있는 구체적 행동으로 끝내세요." + ), + }, + { + "id": "tm-en-control-001", + "suite": "control", + "kind": "english", + "n": 256, + "prompt": "Answer in concise English. Explain one practical difference between caching and persistence for a software project. Use exactly 3 sentences.", + }, + { + "id": "tm-zh-control-001", + "suite": "control", + "kind": "chinese", + "n": 256, + "prompt": "请用简体中文回答。用三句话解释为什么备份和版本控制不是同一件事。不要使用表格。", + }, + { + "id": "tm-format-control-001", + "suite": "control", + "kind": "control_exact", + "n": 256, + "expected": "CONTROL-9001: ABC xyz 12345 !? 한국어 中文", + "prompt": ( + "Think through the instruction if needed. The final non-empty line must copy the following line exactly and add no other characters on that final line:\n" + "CONTROL-9001: ABC xyz 12345 !? 한국어 中文" + ), + }, + { + "id": "tm-ko-exact-001", + "suite": "exact", + "kind": "exact", + "n": 256, + "expected": "가나다라마바사 | 숫자:9101 | 기호: !?.,;:()[]{}", + "prompt": ( + "마지막 빈 줄이 아닌 줄에는 [복사대상] 안의 내용만 한 글자도 바꾸지 말고 그대로 출력하세요.\n" + "[복사대상]\n가나다라마바사 | 숫자:9101 | 기호: !?.,;:()[]{}\n[/복사대상]" + ), + }, + { + "id": "tm-ko-exact-002", + "suite": "exact", + "kind": "exact", + "n": 256, + "expected": "초성열: ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ / 모음열: ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ / 번호:9102", + "prompt": ( + "마지막 빈 줄이 아닌 줄에는 다음 한글 자모 문자열만 그대로 출력하세요.\n" + "<초성열: ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ / 모음열: ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ / 번호:9102>" + ), + }, + ] + + +def build_prompts_expanded30(): + prompts = [] + + daily = [ + ( + "tm30-ko-daily-001", + "조별과제 팀원이 마감 하루 전까지 자료를 보내지 않았습니다.", + "오늘 밤 9시까지 자료를 보내 달라는 요청", + ), + ( + "tm30-ko-daily-002", + "교수님께 출석 처리와 과제 제출 가능 여부를 정중히 물어봐야 합니다.", + "출석 인정 가능 여부와 과제 제출 가능 기한 확인 요청", + ), + ] + for pid, situation, ask in daily: + prompts.append({ + "id": pid, + "suite": "korean", + "kind": "daily", + "n": 256, + "prompt": ( + f"상황: {situation} 한국어로 공손한 메시지를 3문장 이내로 작성하세요. " + f"마지막 문장은 {ask}으로 끝내세요." + ), + }) + + summaries = [ + ( + "tm30-ko-summary-001", + "데이터 편향", + "인공지능 모델은 학습 데이터의 분포를 반영한다. 특정 집단이나 언어가 데이터에서 적게 나타나면 모델의 답변 품질도 낮아질 수 있다. 따라서 모델 평가에서는 전체 평균뿐 아니라 집단별 성능 차이를 따로 확인해야 한다.", + ), + ( + "tm30-ko-summary-002", + "긴 문맥 모델", + "긴 문맥을 처리하는 언어 모델은 많은 문서를 한 번에 읽을 수 있다는 장점이 있다. 그러나 입력이 길어질수록 중요한 정보를 놓치거나 초반 내용을 잊는 문제가 생길 수 있다. 문맥 길이 확장은 검색, 요약, 위치 인식 능력까지 함께 평가해야 한다.", + ), + ] + for pid, title, body in summaries: + prompts.append({ + "id": pid, + "suite": "korean", + "kind": "summary", + "n": 256, + "prompt": ( + f"다음 글을 읽고 한국어로 답하세요.\n\n[글: {title}]\n{body}\n\n" + "작업: 제목을 붙이고 핵심 내용을 정확히 3문장으로 요약하세요. 글에 없는 내용을 추가하지 마세요." + ), + }) + + techs = [ + ("tm30-ko-tech-001", "Git에서 commit, branch, merge의 차이를 초보자에게 설명해 주세요.", ["commit", "branch", "merge"]), + ("tm30-ko-tech-002", "REST API에서 GET과 POST의 차이를 예시와 함께 설명해 주세요.", ["GET", "POST", "조회"]), + ("tm30-ko-tech-003", "Python 함수에 타입 힌트를 붙였을 때의 장점과 한계를 설명해 주세요.", ["타입", "힌트", "런타임"]), + ("tm30-ko-security-001", "SQL Injection을 방어하는 prepared statement의 원리를 공격 절차 없이 설명해 주세요.", ["쿼리", "입력", "분리"]), + ("tm30-ko-container-001", "Docker 컨테이너와 가상머신의 차이를 개발자 관점에서 설명해 주세요.", ["컨테이너", "가상머신", "커널"]), + ("tm30-ko-llm-001", "LLM 추론에서 prefill과 decode 단계가 무엇인지 쉽게 설명해 주세요.", ["prefill", "decode", "토큰"]), + ] + for pid, prompt, keys in techs: + prompts.append({ + "id": pid, + "suite": "korean", + "kind": "tech", + "n": 256, + "keys": keys, + "prompt": f"{prompt} 한국어로 5문장 이내로 답하세요.", + }) + + long_topics = [ + ("tm30-ko-long-001", "수업 보고서", "복잡도와 순환 알고리즘 실습 결과를 보고서로 정리해야 합니다."), + ("tm30-ko-long-002", "프로젝트 계획", "M4 Max 128GB에서 대형 MoE 모델을 실행하기 위한 양자화 실험 계획을 세워야 합니다."), + ("tm30-ko-long-003", "동아리 발표", "신입생에게 CTF와 시스템 보안을 소개하는 5분 발표를 준비해야 합니다."), + ("tm30-ko-long-004", "회의록 정리", "팀 회의에서 나온 의견을 실행 항목과 보류 항목으로 나누어야 합니다."), + ("tm30-ko-long-005", "학습 계획", "자료구조, C언어, 리눅스 기초를 4주 동안 병행해서 공부해야 합니다."), + ("tm30-ko-long-006", "기술 블로그", "한국어 LLM 양자화 실험을 독자가 이해하기 쉽게 블로그 글로 정리해야 합니다."), + ("tm30-ko-long-007", "장학금 신청", "성적뿐 아니라 프로젝트 경험과 성장 가능성을 강조하는 자기소개 문단이 필요합니다."), + ("tm30-ko-long-008", "모델 평가", "KMMLU와 장문 지시문 평가 결과를 기준으로 후보 모델을 비교해야 합니다."), + ] + for pid, topic, bg in long_topics: + prompts.append({ + "id": pid, + "suite": "long", + "kind": "long", + "n": 512, + "prompt": ( + f"다음 조건을 모두 만족하는 한국어 답변을 작성하세요.\n\n상황: {topic}\n세부 배경: {bg}\n\n" + "작성 조건:\n1. 첫 줄 제목은 20자 이내입니다.\n2. 첫 문단은 문제 상황을 2문장으로 요약합니다.\n" + "3. 핵심 목표를 정확히 3개 bullet로 정리합니다.\n4. 실행 계획은 '단계 N:' 형식으로 3단계 이상 작성합니다.\n" + "5. 위험 요소 2개와 완화 방법 2개를 포함합니다.\n6. 표는 사용하지 마세요.\n" + "7. 마지막 문단은 오늘 바로 할 수 있는 첫 행동 1개로 끝내세요.\n8. 전체 답변은 한국어 공손체로 작성하세요." + ), + }) + + exact_cases = [ + ("tm30-ko-exact-001", "가나다라마바사 | 숫자:9301 | 기호: !?.,;:()[]{}"), + ("tm30-ko-exact-002", "경로: /Users/kch3drian/models/한국어 캘리브레이션/93/파일.txt"), + ("tm30-ko-exact-003", "{\"lang\":\"ko\",\"task\":\"expert_trace\",\"문장\":\"한국어 토큰을 보존하세요\",\"id\":9303}"), + ("tm30-ko-exact-004", "초성열: ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ / 모음열: ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ / 번호:9304"), + ("tm30-ko-exact-005", "문장A: 나는 밥을 먹었다.\n문장B: 나는 밥을 먹었다.\n문장C: 나는\t밥을\t먹었다.\nID=9305"), + ("tm30-ko-exact-006", "<정확복사 id='9306'>서울-대전-대구-부산 / 3.14159 / 끝."), + ] + for pid, expected in exact_cases: + prompts.append({ + "id": pid, + "suite": "exact", + "kind": "exact", + "n": 256, + "expected": expected, + "prompt": ( + "마지막 빈 줄이 아닌 줄에는 [복사대상] 안의 내용만 한 글자도 바꾸지 말고 그대로 출력하세요.\n" + f"[복사대상]\n{expected}\n[/복사대상]" + ), + }) + + controls = [ + ("tm30-en-control-001", "english", "Answer in concise English. Explain why cache warming can change benchmark results. Use exactly 3 sentences.", None), + ("tm30-en-control-002", "english", "Answer in concise English. Compare quantization and pruning in machine learning deployment. Use exactly 4 sentences.", None), + ("tm30-zh-control-001", "chinese", "请用简体中文回答。用三句话解释为什么备份和版本控制不是同一件事。不要使用表格。", None), + ("tm30-zh-control-002", "chinese", "请用简体中文回答。用三句话说明模型评估为什么需要保留测试集。不要使用表格。", None), + ("tm30-format-control-001", "control_exact", "The final non-empty line must copy this exactly:\nCONTROL-9307: ABC xyz 12345 !? 한국어 中文", "CONTROL-9307: ABC xyz 12345 !? 한국어 中文"), + ("tm30-format-control-002", "control_exact", "The final non-empty line must copy this exactly:\nCONTROL-9308: q4_K iq2_xxs 256 experts 한국어 中文", "CONTROL-9308: q4_K iq2_xxs 256 experts 한국어 中文"), + ] + for pid, kind, prompt, expected in controls: + prompts.append({ + "id": pid, + "suite": "control", + "kind": kind, + "n": 256, + "expected": expected, + "prompt": prompt, + }) + + assert len(prompts) == 30 + return prompts + + +def escape_tsv(text): + return ( + str(text) + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + +def write_batch_tsv(path, items): + system = "You are a helpful assistant" + with path.open("w", encoding="utf-8") as f: + for item in items: + f.write( + "\t".join([ + escape_tsv(item["id"]), + str(item["n"]), + escape_tsv(system), + escape_tsv(item["prompt"]), + ]) + + "\n" + ) + + +def run_model(model_name, model_path, items, out_dir, ctx, suffix): + stem = f"thinkmax{suffix}_{model_name}" + batch_in = out_dir / f"{stem}.tsv" + batch_out = out_dir / f"{stem}.jsonl" + batch_err = out_dir / f"{stem}.stderr.log" + batch_out.unlink(missing_ok=True) + batch_err.unlink(missing_ok=True) + write_batch_tsv(batch_in, items) + + cmd = [ + "./ds4", + "--metal", + *model_cli_args(model_path), + "--ctx", + str(ctx), + "--think-max", + "--temp", + "0", + "--seed", + "7", + "--batch-prompts-tsv", + str(batch_in), + "--batch-output-jsonl", + str(batch_out), + ] + t0 = time.time() + proc = subprocess.run( + cmd, + cwd=str(ROOT), + text=True, + capture_output=True, + timeout=72000, + ) + wall_seconds = time.time() - t0 + batch_err.write_text(proc.stderr, encoding="utf-8") + + rows = [] + if batch_out.exists(): + with batch_out.open(encoding="utf-8") as f: + for line in f: + if line.strip(): + rows.append(json.loads(line)) + if proc.returncode != 0 and not rows: + raise RuntimeError(f"Think MAX batch failed for {model_name}: {proc.returncode}\n{proc.stderr[-4000:]}") + + by_id = {item["id"]: item for item in items} + converted = [] + for row in rows: + item = by_id.get(row.get("id")) + if not item: + continue + output = row.get("output") or "" + converted.append({ + "id": item["id"], + "suite": item["suite"], + "kind": item["kind"], + "model": model_name, + "returncode": row.get("returncode", 1), + "batch_wall_seconds": round(wall_seconds, 3), + "prompt_tokens": row.get("prompt_tokens"), + "generated_tokens": row.get("generated_tokens"), + "prefill_seconds": row.get("prefill_seconds"), + "decode_seconds": row.get("decode_seconds"), + "prefill_tps": row.get("prefill_tps"), + "generation_tps": row.get("generation_tps"), + "output": output.strip(), + "final_line": last_nonempty_line(output), + "error": row.get("error"), + }) + return converted + + +def score(item, result): + output = result.get("output", "") + final_line = result.get("final_line", "") + if result.get("returncode") != 0 or not output.strip(): + return False + kind = item["kind"] + if kind in {"exact", "control_exact"}: + expected = item.get("expected", "") + return final_line == expected or output.strip() == expected + if kind == "daily": + tail = "\n".join(output.splitlines()[-4:]) + return hangul_ratio(tail) >= 0.55 and sentence_count_ko(tail) <= 5 and ("요" in tail or "습니다" in tail) + if kind == "summary": + return hangul_ratio(output) >= 0.45 and sentence_count_ko(output) >= 3 and "제목" in output + if kind == "tech": + keys = item.get("keys", []) + return hangul_ratio(output) >= 0.25 and sum(k in output for k in keys) >= 2 + if kind == "long": + return hangul_ratio(output) >= 0.35 and "단계 1:" in output and "위험" in output and "|" not in output + if kind == "english": + return latin_ratio(final_line or output) >= 0.6 + if kind == "chinese": + return cjk_ratio(output) >= 0.25 + return True + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=str(DEFAULT_OUT)) + ap.add_argument("--ctx", type=int, default=393216) + ap.add_argument("--models", default="base,worst5q4") + ap.add_argument("--ids", default="") + ap.add_argument("--token-multiplier", type=int, default=1) + ap.add_argument("--suffix", default="") + ap.add_argument("--preset", choices=["standard", "expanded30"], default="standard") + ap.add_argument("--rerun", action="store_true") + args = ap.parse_args() + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + prompts = build_prompts_expanded30() if args.preset == "expanded30" else build_prompts() + if args.ids: + wanted = {x.strip() for x in args.ids.split(",") if x.strip()} + prompts = [p for p in prompts if p["id"] in wanted] + if args.token_multiplier != 1: + prompts = [{**p, "n": p["n"] * args.token_multiplier} for p in prompts] + prompt_by_id = {p["id"]: p for p in prompts} + suffix = f"_{args.suffix}" if args.suffix else "" + (out_dir / f"thinkmax{suffix}_prompts.jsonl").write_text( + "\n".join(json.dumps(p, ensure_ascii=True) for p in prompts) + "\n", + encoding="utf-8", + ) + + raw_path = out_dir / f"thinkmax{suffix}_raw_results.jsonl" + if args.rerun: + raw_path.unlink(missing_ok=True) + selected_models = [m.strip() for m in args.models.split(",") if m.strip()] + all_results = [] + done = set() + if raw_path.exists() and not args.rerun: + with raw_path.open(encoding="utf-8") as f: + for line in f: + if not line.strip(): + continue + row = json.loads(line) + done.add((row["model"], row["id"])) + all_results.append(row) + with raw_path.open("a", encoding="utf-8") as f: + for model_name in selected_models: + todo = [p for p in prompts if (model_name, p["id"]) not in done] + if not todo: + continue + print(f"RUN_THINKMAX model={model_name} ctx={args.ctx} n={len(todo)}", flush=True) + results = run_model(model_name, MODELS[model_name], todo, out_dir, args.ctx, suffix) + for res in results: + f.write(json.dumps(res, ensure_ascii=True) + "\n") + f.flush() + all_results.extend(results) + + score_rows = [] + for res in all_results: + item = prompt_by_id[res["id"]] + output = res.get("output", "") + final_line = res.get("final_line", "") + score_rows.append({ + "id": res["id"], + "suite": res["suite"], + "kind": res["kind"], + "model": res["model"], + "returncode": res["returncode"], + "prompt_tokens": res["prompt_tokens"], + "generated_tokens": res["generated_tokens"], + "prefill_tps": res["prefill_tps"], + "generation_tps": res["generation_tps"], + "pass": score(item, res), + "output_chars": len(output), + "final_line_chars": len(final_line), + "hangul_ratio": round(hangul_ratio(output), 4), + "final_hangul_ratio": round(hangul_ratio(final_line), 4), + "latin_ratio": round(latin_ratio(output), 4), + "cjk_ratio": round(cjk_ratio(output), 4), + }) + + with (out_dir / f"thinkmax{suffix}_scores.csv").open("w", newline="", encoding="utf-8") as f: + fields = [ + "id", "suite", "kind", "model", "returncode", "prompt_tokens", "generated_tokens", + "prefill_tps", "generation_tps", "pass", "output_chars", "final_line_chars", + "hangul_ratio", "final_hangul_ratio", "latin_ratio", "cjk_ratio", + ] + w = csv.DictWriter(f, fieldnames=fields) + w.writeheader() + w.writerows(score_rows) + + summary = {} + for model_name in selected_models: + xs = [r for r in score_rows if r["model"] == model_name] + summary[model_name] = { + "n": len(xs), + "pass": sum(1 for r in xs if r["pass"]), + "pass_rate": sum(1 for r in xs if r["pass"]) / len(xs) if xs else None, + "avg_prefill_tps": sum((r["prefill_tps"] or 0) for r in xs) / len(xs) if xs else None, + "avg_generation_tps": sum((r["generation_tps"] or 0) for r in xs) / len(xs) if xs else None, + "avg_generated_tokens": sum((r["generated_tokens"] or 0) for r in xs) / len(xs) if xs else None, + } + for suite in sorted(set(r["suite"] for r in xs)): + ss = [r for r in xs if r["suite"] == suite] + summary[model_name][f"suite_{suite}"] = { + "n": len(ss), + "pass": sum(1 for r in ss if r["pass"]), + "avg_generation_tps": sum((r["generation_tps"] or 0) for r in ss) / len(ss) if ss else None, + } + + (out_dir / f"thinkmax{suffix}_summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/eval_ds4_project.py b/tools/eval_ds4_project.py new file mode 100644 index 00000000..b1a6b368 --- /dev/null +++ b/tools/eval_ds4_project.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +import argparse +import csv +import json +import re +import subprocess +import time +from pathlib import Path + + +ROOT = Path("/Users/kch3dri4n/llm_provide/ds4") +OUT = Path("/tmp/ds4-ko-cal/structured_eval_layerq4") + +MODELS = { + "base": "ds4flash.gguf", + "worst5q4": "gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf", + "latestable5q4": "gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-LateStable5Q4-chat-v2.gguf", + "layer10q4": "gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Layer10Q4-chat-v2.gguf", + "thinktop32_sidecar": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf", + }, + "mixed32_sidecar": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-Mixed32-from-base.sidecar.gguf", + }, + "thinktop32_late5_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Late5-HF-FP4.sidecar.gguf", + }, + "thinktop32_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-L10-HF-FP4.sidecar.gguf", + }, + "thinktop64_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop64-L10-HF-FP4.sidecar.gguf", + }, + "thinktop128_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop128-L10-HF-FP4.sidecar.gguf", + }, + "full256_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-Full256-L10-HF-FP4.sidecar.gguf", + }, + "thinktop64_l10_base_fp8_q4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop64-L10-BaseFP8-Q4.sidecar.gguf", + }, + "thinktop128_l10_base_fp8_q4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop128-L10-BaseFP8-Q4.sidecar.gguf", + }, + "full256_l10_base_fp8_q4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-Full256-L10-BaseFP8-Q4.sidecar.gguf", + }, + "thinktop32_l8_l12_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-L8-L12-HF-FP4.sidecar.gguf", + }, +} + + +def model_cli_args(model_spec): + if isinstance(model_spec, dict): + return ["-m", model_spec["base"], "--bitlift-sidecar", model_spec["sidecar"]] + return ["-m", model_spec] + + +def hangul_ratio(text): + chars = [c for c in text if not c.isspace()] + if not chars: + return 0.0 + return sum(1 for c in chars if re.match(r"[가-힣ㄱ-ㅎㅏ-ㅣ]", c)) / len(chars) + + +def latin_ratio(text): + chars = [c for c in text if c.isalpha()] + if not chars: + return 0.0 + return sum(1 for c in chars if ("A" <= c <= "Z") or ("a" <= c <= "z")) / len(chars) + + +def cjk_ratio(text): + chars = [c for c in text if not c.isspace()] + if not chars: + return 0.0 + return sum(1 for c in chars if "\u4e00" <= c <= "\u9fff") / len(chars) + + +def sentence_count_ko(text): + return len([x for x in re.split(r"[.!?。!?\n]+", text.strip()) if x.strip()]) + + +def build_prompts(): + prompts = [] + + situations = [ + ("group_late", "조별과제 팀원이 자료를 늦게 보내고 있습니다", "오늘 밤 9시까지 자료를 보내 달라고 요청"), + ("prof_extension", "교수님께 과제 제출 가능 여부를 문의해야 합니다", "오늘 오후 5시까지 제출 가능 여부를 확인 요청"), + ("club_room", "동아리 신입 부원이 회의 장소를 헷갈렸습니다", "학생회관 302호로 와 달라고 안내"), + ("dorm_call", "룸메이트의 밤늦은 통화가 수면에 방해됩니다", "밤 11시 이후에는 복도에서 통화해 달라고 요청"), + ("interview_change", "아르바이트 면접 시간을 바꿔야 합니다", "내일 오후 3시 또는 5시 가능 여부 확인 요청"), + ] + for i in range(20): + sid, desc, ask = situations[i % len(situations)] + prompts.append({ + "id": f"ko-held-daily-{i:03d}", + "suite": "korean100", + "kind": "daily", + "n": 96, + "prompt": f"상황: {desc}. 상대가 기분 나쁘지 않게 한국어 메시지를 3문장 이내로 작성하세요. 마지막 문장은 구체적인 요청으로 끝내세요. 요청 내용: {ask}.", + }) + + passages = [ + ("도시 열섬", "도시의 아스팔트와 콘크리트는 낮 동안 열을 저장하고 밤에도 천천히 방출한다. 차량과 냉방기의 폐열이 더해지면 주변보다 기온이 높아질 수 있다. 나무 그늘과 옥상 녹화는 열섬 완화에 도움이 된다."), + ("온라인 수업", "온라인 수업은 이동 시간을 줄이고 녹화 강의를 반복해서 볼 수 있다는 장점이 있다. 그러나 학습 일정을 스스로 관리하지 못하면 집중도가 낮아질 수 있다. 실시간 질문과 짧은 퀴즈는 약점을 보완한다."), + ("오픈소스", "오픈소스는 누구나 코드를 검토하고 수정할 수 있다는 특징이 있다. 버그를 빠르게 발견할 수 있지만 유지보수자가 부족하면 보안 패치가 늦어질 수 있다. 사용자는 업데이트 주기와 커뮤니티 상태도 확인해야 한다."), + ("개인정보 보호", "개인정보 보호는 서비스를 만들 때 설계 단계부터 고려해야 한다. 필요한 정보만 수집하고 보관 기간을 제한해야 한다. 사용자가 자신의 정보를 확인하고 삭제할 수 있어야 신뢰를 지킬 수 있다."), + ("학습 루틴", "공부 계획은 완벽함보다 지속 가능성이 중요하다. 하루에 많은 시간을 몰아서 공부하면 쉽게 지칠 수 있다. 짧은 복습과 주기적인 점검은 장기 기억에 도움이 된다."), + ] + for i in range(20): + title, body = passages[i % len(passages)] + prompts.append({ + "id": f"ko-held-summary-{i:03d}", + "suite": "korean100", + "kind": "summary", + "n": 128, + "prompt": f"다음 글을 읽고 한국어로 답하세요.\n\n[글: {title}]\n{body}\n\n작업: 제목을 붙이고 핵심 내용을 정확히 3문장으로 요약하세요. 글에 없는 내용을 추가하지 마세요.", + }) + + tech = [ + ("prepared_statement", "SQL Injection을 방어하는 prepared statement의 원리를 공격 절차 없이 설명해 주세요.", ["쿼리", "입력", "분리"]), + ("hash_salt", "비밀번호를 평문으로 저장하면 안 되는 이유와 해시와 솔트의 개념을 설명해 주세요.", ["해시", "솔트", "평문"]), + ("aslr", "ASLR이 메모리 공격을 어렵게 만드는 이유를 개념적으로 설명해 주세요.", ["주소", "무작위", "메모리"]), + ("git", "Git에서 commit, branch, merge의 차이를 초보자에게 설명해 주세요.", ["commit", "branch", "merge"]), + ("rest", "REST API에서 GET과 POST의 차이를 예시와 함께 설명해 주세요.", ["GET", "POST", "조회"]), + ] + for i in range(20): + sid, task, keys = tech[i % len(tech)] + prompts.append({ + "id": f"ko-held-tech-{i:03d}", + "suite": "korean100", + "kind": "tech", + "n": 128, + "keys": keys, + "prompt": f"{task} 한국어로 5문장 이내로 간결하게 답하고, 불필요한 공격 절차나 과장된 표현은 넣지 마세요.", + }) + + copy_cases = [ + "가나다라마바사 | 숫자:{num} | 기호: !?.,;:()[]{{}}", + "문장A: 나는 밥을 먹었다.\n문장B: 나는 밥을 먹었다.\n문장C: 나는\t밥을\t먹었다.\nID={num}", + "{{\"lang\":\"ko\",\"task\":\"copy\",\"문장\":\"한국어 토큰을 보존하세요\",\"id\":{num}}}", + "|항목|값|\n|---|---|\n|모델|DS4-KR|\n|번호|{num}|\n|비고|한글/영문 혼합|", + "<정확복사 id='{num}'>서울-대전-대구-부산 / 3.14159 / 끝.", + ] + for i in range(20): + expected = copy_cases[i % len(copy_cases)].format(num=2000 + i) + prompts.append({ + "id": f"ko-held-copy-{i:03d}", + "suite": "korean100", + "kind": "exact", + "n": 96, + "expected": expected, + "prompt": f"아래 [복사대상] 안의 내용을 한 글자도 바꾸지 말고 그대로 출력하세요. 설명을 붙이지 마세요.\n[복사대상]\n{expected}\n[/복사대상]", + }) + + long_topics = [ + ("수업 보고서", "복잡도와 순환 알고리즘 실습 결과를 보고서로 정리해야 합니다"), + ("포트폴리오", "AI컴퓨터공학부 1학년 학생이 보안 전문가 진로를 설명해야 합니다"), + ("회의록 정리", "팀 회의 의견을 실행 항목과 보류 항목으로 나누어야 합니다"), + ("학습 계획", "자료구조, C언어, 리눅스 기초를 4주 동안 병행해서 공부해야 합니다"), + ("기술 블로그", "한국어 LLM 양자화 실험을 독자가 이해하기 쉽게 정리해야 합니다"), + ] + for i in range(20): + topic, bg = long_topics[i % len(long_topics)] + prompts.append({ + "id": f"ko-held-long-{i:03d}", + "suite": "korean100", + "kind": "long", + "n": 220, + "prompt": ( + f"다음 조건을 모두 만족하는 한국어 답변을 작성하세요.\n\n상황: {topic}\n세부 배경: {bg}.\n\n" + "작성 조건:\n1. 첫 줄에 20자 이내 제목을 씁니다.\n2. 첫 문단은 문제 상황을 2문장으로 요약합니다.\n" + "3. 핵심 목표를 정확히 3개 bullet로 정리합니다.\n4. 실행 계획은 '단계 N:' 형식으로 3단계 이상 작성합니다.\n" + "5. 표는 사용하지 마세요.\n6. 마지막 문단은 바로 할 수 있는 첫 행동 1개로 끝내세요.\n7. 전체 답변은 한국어 공손체로 작성하세요." + ), + }) + + # Degradation/control suites. + for i in range(20): + prompts.append({ + "id": f"en-control-{i:03d}", + "suite": "control60", + "kind": "english", + "n": 96, + "prompt": f"Answer in concise English. Explain one practical difference between caching and persistence for a software project. Use exactly {2 + (i % 2)} sentences.", + }) + for i in range(20): + prompts.append({ + "id": f"zh-control-{i:03d}", + "suite": "control60", + "kind": "chinese", + "n": 96, + "prompt": f"请用简体中文回答。用三句话解释为什么备份和版本控制不是同一件事。不要使用表格。编号:{i}", + }) + for i in range(20): + expected = f"CONTROL-{3000+i}: ABC xyz 12345 !? 한국어 中文" + prompts.append({ + "id": f"fmt-control-{i:03d}", + "suite": "control60", + "kind": "control_exact", + "n": 80, + "expected": expected, + "prompt": f"Copy the following line exactly and add no explanation:\n{expected}", + }) + + # Extra exact/long prompts beyond korean100. + for i in range(20): + expected = f"초성열: ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ / 모음열: ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ / 번호:{4000+i}" + prompts.append({ + "id": f"extra-copy-{i:03d}", + "suite": "exact_long_extra", + "kind": "exact", + "n": 96, + "expected": expected, + "prompt": f"다음 한글 자모 문자열을 그대로 반복해서 출력하세요.\n<{expected}>", + }) + for i in range(10): + prompts.append({ + "id": f"extra-long-{i:03d}", + "suite": "exact_long_extra", + "kind": "long", + "n": 260, + "prompt": ( + "다음 조건을 모두 만족하는 한국어 실행 계획을 작성하세요.\n" + "상황: 한국어 모델 평가 결과를 발표 자료로 정리해야 합니다.\n" + "조건: 첫 줄 제목은 15자 이내, 첫 문단은 2문장, 목표 bullet은 정확히 3개, " + "각 단계는 '단계 N:'으로 시작, 위험 요소 2개와 완화 방법 2개 포함, 표 사용 금지, " + "마지막 문장은 오늘 바로 할 수 있는 구체적 행동으로 끝내세요." + ), + }) + + return prompts + + +def escape_tsv(text): + return ( + text.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + +def write_batch_tsv(path, items): + system = "You are a helpful assistant" + with path.open("w", encoding="utf-8") as f: + for item in items: + f.write( + "\t".join([ + escape_tsv(item["id"]), + str(item["n"]), + escape_tsv(system), + escape_tsv(item["prompt"]), + ]) + + "\n" + ) + + +def run_batch(model_name, model_path, items, suite_key): + if not items: + return [] + batch_in = OUT / f"batch_{suite_key}_{model_name}.tsv" + batch_out = OUT / f"batch_{suite_key}_{model_name}.jsonl" + batch_err = OUT / f"batch_{suite_key}_{model_name}.stderr.log" + batch_out.unlink(missing_ok=True) + batch_err.unlink(missing_ok=True) + write_batch_tsv(batch_in, items) + + cmd = [ + "./ds4", + "--metal", + *model_cli_args(model_path), + "-c", + "4096", + "--nothink", + "--temp", + "0", + "--seed", + "1", + "--batch-prompts-tsv", + str(batch_in), + "--batch-output-jsonl", + str(batch_out), + ] + t0 = time.time() + proc = subprocess.run( + cmd, + cwd=str(ROOT), + text=True, + capture_output=True, + timeout=36000, + ) + wall_seconds = time.time() - t0 + batch_err.write_text(proc.stderr, encoding="utf-8") + rows = [] + if batch_out.exists(): + with batch_out.open(encoding="utf-8") as f: + for line in f: + if line.strip(): + rows.append(json.loads(line)) + if proc.returncode != 0 and not rows: + raise RuntimeError(f"batch generation failed for {model_name}: {proc.returncode}\n{proc.stderr[-2000:]}") + + item_by_id = {item["id"]: item for item in items} + converted = [] + for row in rows: + item = item_by_id.get(row.get("id")) + if not item: + continue + seconds = (row.get("prefill_seconds") or 0.0) + (row.get("decode_seconds") or 0.0) + converted.append({ + "id": item["id"], + "suite": item["suite"], + "kind": item["kind"], + "model": model_name, + "returncode": row.get("returncode", 1), + "seconds": round(seconds, 3), + "batch_wall_seconds": round(wall_seconds, 3), + "prompt_tokens": row.get("prompt_tokens"), + "generated_tokens": row.get("generated_tokens"), + "prefill_tps": row.get("prefill_tps"), + "generation_tps": row.get("generation_tps"), + "output": (row.get("output") or "").strip(), + "error": row.get("error"), + }) + return converted + + +def score(item, output): + kind = item["kind"] + if kind in {"exact", "control_exact"}: + return output == item.get("expected", "") + if kind == "daily": + return hangul_ratio(output) >= 0.65 and sentence_count_ko(output) <= 4 and ("요" in output or "습니다" in output) + if kind == "summary": + return hangul_ratio(output) >= 0.65 and sentence_count_ko(output) >= 3 and "제목" in output[:40] + if kind == "tech": + keys = item.get("keys", []) + return hangul_ratio(output) >= 0.35 and sentence_count_ko(output) <= 7 and sum(k in output for k in keys) >= 2 + if kind == "long": + lines = [x.rstrip() for x in output.splitlines() if x.strip()] + title_ok = bool(lines) and len(lines[0]) <= 20 and "|" not in output + bullets = len([x for x in lines if x.lstrip().startswith(("-", "*"))]) + steps = len(re.findall(r"단계\s*\d+:", output)) + return hangul_ratio(output) >= 0.55 and title_ok and bullets >= 3 and steps >= 3 + if kind == "english": + return latin_ratio(output) >= 0.75 and cjk_ratio(output) < 0.05 + if kind == "chinese": + return cjk_ratio(output) >= 0.35 + return bool(output.strip()) + + +def main(): + global OUT + ap = argparse.ArgumentParser() + ap.add_argument("--suite", action="append", required=True) + ap.add_argument("--models", default="base,worst5q4") + ap.add_argument("--out", default=str(OUT)) + ap.add_argument("--rerun", action="store_true") + args = ap.parse_args() + + OUT = Path(args.out) + OUT.mkdir(parents=True, exist_ok=True) + prompts = [p for p in build_prompts() if p["suite"] in set(args.suite)] + prompt_by_id = {p["id"]: p for p in prompts} + (OUT / "prompts.jsonl").write_text("\n".join(json.dumps(p, ensure_ascii=False) for p in prompts) + "\n") + + result_path = OUT / "raw_results.jsonl" + if args.rerun and result_path.exists(): + result_path.unlink() + done = set() + if result_path.exists() and not args.rerun: + with result_path.open() as f: + for line in f: + r = json.loads(line) + if r.get("suite") in set(args.suite): + done.add((r["model"], r["id"])) + + selected_models = [m.strip() for m in args.models.split(",") if m.strip()] + suite_key = "_".join(args.suite) + with result_path.open("a") as out: + for model_name in selected_models: + todo = [item for item in prompts if (model_name, item["id"]) not in done] + if not todo: + continue + print(f"RUN_BATCH suites={','.join(args.suite)} model={model_name} n={len(todo)}", flush=True) + for res in run_batch(model_name, MODELS[model_name], todo, suite_key): + out.write(json.dumps(res, ensure_ascii=False) + "\n") + out.flush() + + rows = [] + with result_path.open() as f: + for line in f: + r = json.loads(line) + if r["id"] not in prompt_by_id: + continue + item = prompt_by_id[r["id"]] + rows.append({ + **{k: r.get(k) for k in ["id", "suite", "kind", "model", "returncode", "seconds", "prefill_tps", "generation_tps", "prompt_tokens", "generated_tokens"]}, + "pass": score(item, r.get("output", "")), + "output_chars": len(r.get("output", "")), + "hangul_ratio": round(hangul_ratio(r.get("output", "")), 4), + "latin_ratio": round(latin_ratio(r.get("output", "")), 4), + "cjk_ratio": round(cjk_ratio(r.get("output", "")), 4), + }) + + with (OUT / "scores.csv").open("w", newline="") as f: + fields = ["id", "suite", "kind", "model", "returncode", "seconds", "prefill_tps", "generation_tps", "prompt_tokens", "generated_tokens", "pass", "output_chars", "hangul_ratio", "latin_ratio", "cjk_ratio"] + w = csv.DictWriter(f, fieldnames=fields) + w.writeheader() + w.writerows(rows) + + summary = {} + for suite in sorted(set(r["suite"] for r in rows)): + summary[suite] = {} + for model in selected_models: + xs = [r for r in rows if r["suite"] == suite and r["model"] == model] + summary[suite][model] = { + "n": len(xs), + "pass": sum(1 for r in xs if r["pass"]), + "pass_rate": sum(1 for r in xs if r["pass"]) / len(xs) if xs else None, + "avg_prefill_tps": sum(r["prefill_tps"] or 0 for r in xs) / len(xs) if xs else None, + "avg_generation_tps": sum(r["generation_tps"] or 0 for r in xs) / len(xs) if xs else None, + } + (OUT / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2)) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/eval_kmmlu_sample.py b/tools/eval_kmmlu_sample.py new file mode 100644 index 00000000..85eb60cc --- /dev/null +++ b/tools/eval_kmmlu_sample.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +import argparse +import csv +import json +import random +import re +import subprocess +import time +from collections import Counter, defaultdict +from pathlib import Path + +from huggingface_hub import HfApi, hf_hub_download + + +ROOT = Path("/Users/kch3dri4n/llm_provide/ds4") +DEFAULT_OUT = Path("/tmp/ds4-ko-cal/kmmlu_sample100_20260519") + +MODELS = { + "base": "ds4flash.gguf", + "worst5q4": "gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Worst5Q4-chat-v2-imatrix.gguf", + "latestable5q4": "gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-LateStable5Q4-chat-v2.gguf", + "layer10q4": "gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-KR-Layer10Q4-chat-v2.gguf", + "thinktop32_sidecar": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Layer10Q4.sidecar.gguf", + }, + "mixed32_sidecar": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-Mixed32-from-base.sidecar.gguf", + }, + "thinktop32_late5_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-Late5-HF-FP4.sidecar.gguf", + }, + "thinktop32_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-L10-HF-FP4.sidecar.gguf", + }, + "thinktop64_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop64-L10-HF-FP4.sidecar.gguf", + }, + "thinktop128_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop128-L10-HF-FP4.sidecar.gguf", + }, + "full256_l10_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-Full256-L10-HF-FP4.sidecar.gguf", + }, + "thinktop64_l10_base_fp8_q4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop64-L10-BaseFP8-Q4.sidecar.gguf", + }, + "thinktop128_l10_base_fp8_q4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop128-L10-BaseFP8-Q4.sidecar.gguf", + }, + "full256_l10_base_fp8_q4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-Full256-L10-BaseFP8-Q4.sidecar.gguf", + }, + "thinktop32_l8_l12_hf_fp4": { + "base": "ds4flash.gguf", + "sidecar": "gguf/DeepSeek-V4-Flash-KR-ThinkTop32-L8-L12-HF-FP4.sidecar.gguf", + }, +} + + +def model_cli_args(model_spec): + if isinstance(model_spec, dict): + return ["-m", model_spec["base"], "--bitlift-sidecar", model_spec["sidecar"]] + return ["-m", model_spec] + + +def escape_tsv(text: str) -> str: + return ( + text.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + +def unescape_model_text(text: str) -> str: + return text.strip() + + +def answer_to_digit(raw: str): + if raw is None: + return None + s = str(raw).strip() + if s in {"1", "2", "3", "4"}: + return s + if s.upper() in {"A", "B", "C", "D"}: + return str("ABCD".index(s.upper()) + 1) + return None + + +def extract_prediction(output: str): + text = unescape_model_text(output) + m = re.search(r"(? str: + return ( + text.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + +def hangul_ratio(text: str) -> float: + chars = [c for c in text if not c.isspace()] + if not chars: + return 0.0 + return sum(1 for c in chars if re.match(r"[가-힣ㄱ-ㅎㅏ-ㅣ]", c)) / len(chars) + + +def first_nonempty_line(text: str) -> str: + for line in text.splitlines(): + if line.strip(): + return line.strip() + return "" + + +def count_bullets(text: str) -> int: + return sum(1 for line in text.splitlines() if re.match(r"^\s*[-*]\s+\S", line)) + + +def count_steps(text: str) -> int: + return len(re.findall(r"단계\s*\d+\s*:", text)) + + +def has_markdown_table(text: str) -> bool: + lines = [line.strip() for line in text.splitlines()] + return any(line.startswith("|") and line.endswith("|") for line in lines) + + +def has_polite_style(text: str) -> bool: + return bool(re.search(r"(습니다|합니다|드립니다|주세요|십시오|입니다)", text)) + + +def build_prompts(): + topics = [ + ("수업 보고서", "복잡도와 순환 알고리즘 실습 결과를 보고서로 정리해야 합니다."), + ("포트폴리오", "AI컴퓨터공학부 1학년 학생이 보안 전문가 진로를 설명해야 합니다."), + ("회의록 정리", "팀 회의 의견을 실행 항목과 보류 항목으로 나누어야 합니다."), + ("학습 계획", "자료구조, C언어, 리눅스 기초를 4주 동안 병행해서 공부해야 합니다."), + ("기술 블로그", "한국어 LLM 양자화 실험을 독자가 이해하기 쉽게 정리해야 합니다."), + ] + prompts = [] + + for i in range(15): + topic, bg = topics[i % len(topics)] + prompts.append({ + "id": f"longv2-basic-{i:03d}", + "kind": "basic_plan", + "n": 360, + "title_max": 20, + "exact_bullets": 3, + "min_steps": 3, + "requires_risk": False, + "requires_validation": False, + "requires_terms": [], + "prompt": ( + f"다음 조건을 모두 만족하는 한국어 답변을 작성하세요.\n\n상황: {topic}\n세부 배경: {bg}\n\n" + "작성 조건:\n1. 첫 줄에 20자 이내 제목을 씁니다.\n2. 첫 문단은 문제 상황을 2문장으로 요약합니다.\n" + "3. 핵심 목표를 정확히 3개 bullet로 정리합니다.\n4. 실행 계획은 '단계 N:' 형식으로 3단계 이상 작성합니다.\n" + "5. 표는 사용하지 마세요.\n6. 마지막 문단은 앞으로 바로 할 수 있는 첫 행동 1개로 끝내세요.\n" + "7. 전체 답변은 한국어 공손체로 작성하세요." + ), + }) + + for i in range(15): + topic, bg = topics[i % len(topics)] + prompts.append({ + "id": f"longv2-risk-{i:03d}", + "kind": "risk_plan", + "n": 420, + "title_max": 20, + "exact_bullets": 3, + "min_steps": 4, + "requires_risk": True, + "requires_validation": False, + "requires_terms": [], + "prompt": ( + f"다음 조건을 모두 만족하는 한국어 실행 계획을 작성하세요.\n\n상황: {topic}\n세부 배경: {bg}\n\n" + "작성 조건:\n1. 첫 줄 제목은 20자 이내로 작성합니다.\n2. 핵심 목표는 정확히 3개 bullet로 씁니다.\n" + "3. 실행 계획은 '단계 N:' 형식으로 4단계 이상 작성합니다.\n4. 위험 요소 2개와 완화 방법 2개를 반드시 포함합니다.\n" + "5. 확인되지 않은 성과를 단정하지 않습니다.\n6. 표는 사용하지 않습니다.\n7. 마지막 문장은 오늘 바로 할 수 있는 구체적 행동으로 끝냅니다." + ), + }) + + term_sets = [ + ("양자화", "모델 가중치 표현 정밀도를 낮춰 용량을 줄이는 방법"), + ("라우터", "MoE 모델에서 사용할 expert를 고르는 구성요소"), + ("prefill", "입력 프롬프트를 한 번에 처리하는 단계"), + ("decode", "토큰을 하나씩 생성하는 단계"), + ("sidecar", "기존 모델 옆에 추가로 붙이는 보조 텐서 묶음"), + ] + for i in range(15): + topic, bg = topics[i % len(topics)] + term, meaning = term_sets[i % len(term_sets)] + prompts.append({ + "id": f"longv2-terms-{i:03d}", + "kind": "term_explain", + "n": 420, + "title_max": 20, + "exact_bullets": 3, + "min_steps": 3, + "requires_risk": False, + "requires_validation": False, + "requires_terms": [term], + "prompt": ( + f"다음 조건을 모두 만족하는 한국어 설명문을 작성하세요.\n\n상황: {topic}\n세부 배경: {bg}\n" + f"반드시 설명할 용어: {term} - {meaning}\n\n" + "작성 조건:\n1. 첫 줄 제목은 20자 이내입니다.\n2. 첫 문단은 상황을 2문장으로 요약합니다.\n" + "3. 핵심 목표를 정확히 3개 bullet로 정리합니다.\n4. 전문용어에는 괄호 안에 짧은 설명을 붙입니다.\n" + "5. 실행 계획은 '단계 N:' 형식으로 3단계 이상 작성합니다.\n6. 표는 쓰지 않습니다.\n7. 전체 답변은 한국어 공손체로 작성합니다." + ), + }) + + for i in range(15): + topic, bg = topics[i % len(topics)] + prompts.append({ + "id": f"longv2-verify-{i:03d}", + "kind": "validation_plan", + "n": 440, + "title_max": 20, + "exact_bullets": 3, + "min_steps": 3, + "requires_risk": False, + "requires_validation": True, + "requires_terms": [], + "prompt": ( + f"다음 조건을 모두 만족하는 한국어 계획서를 작성하세요.\n\n상황: {topic}\n세부 배경: {bg}\n\n" + "작성 조건:\n1. 첫 줄 제목은 20자 이내입니다.\n2. 문제 상황을 2문장으로 요약합니다.\n" + "3. 핵심 목표는 정확히 3개 bullet입니다.\n4. 실행 계획은 '단계 N:' 형식으로 3단계 이상입니다.\n" + "5. 불확실한 내용은 단정하지 말고 '검증 필요 항목'으로 분리합니다.\n" + "6. 표는 사용하지 않습니다.\n7. 마지막 문단은 바로 할 수 있는 첫 행동 1개로 끝냅니다." + ), + }) + + return prompts + + +def write_batch_tsv(path: Path, items): + system = "You are a helpful Korean writing assistant. Follow every formatting constraint exactly." + with path.open("w", encoding="utf-8") as f: + for item in items: + f.write( + "\t".join([ + escape_tsv(item["id"]), + str(item["n"]), + escape_tsv(system), + escape_tsv(item["prompt"]), + ]) + + "\n" + ) + + +def score_output(item, row): + text = (row.get("output") or "").strip() + title = first_nonempty_line(text) + checks = { + "has_output": bool(text), + "title_len_ok": bool(title) and len(title) <= item["title_max"], + "korean_ratio_ok": hangul_ratio(text) >= 0.35, + "polite_ok": has_polite_style(text), + "bullet_count_ok": count_bullets(text) == item["exact_bullets"], + "step_count_ok": count_steps(text) >= item["min_steps"], + "no_table_ok": not has_markdown_table(text), + "not_obviously_truncated": (row.get("generated_tokens") or 0) < item["n"], + } + if item["requires_risk"]: + checks["risk_count_ok"] = len(re.findall(r"위험\s*요소|위험", text)) >= 2 + checks["mitigation_count_ok"] = len(re.findall(r"완화\s*방법|완화", text)) >= 2 + if item["requires_validation"]: + checks["validation_section_ok"] = bool(re.search(r"검증 필요|확인 필요|검토 필요", text)) + for term in item["requires_terms"]: + checks[f"term_{term}_ok"] = term in text and bool(re.search(re.escape(term) + r"[^\n]{0,20}\(", text)) + + passed = sum(1 for v in checks.values() if v) + total = len(checks) + return { + "criteria_passed": passed, + "criteria_total": total, + "score": passed / total if total else 0.0, + "pass": passed / total >= 0.80 if total else False, + "checks": checks, + "title": title, + "bullet_count": count_bullets(text), + "step_count": count_steps(text), + "hangul_ratio": hangul_ratio(text), + } + + +def run_model(model_name, model_path, items, out_dir: Path, ctx: int): + batch_in = out_dir / f"batch_longv2_{model_name}.tsv" + batch_out = out_dir / f"batch_longv2_{model_name}.jsonl" + batch_err = out_dir / f"batch_longv2_{model_name}.stderr.log" + batch_out.unlink(missing_ok=True) + batch_err.unlink(missing_ok=True) + write_batch_tsv(batch_in, items) + + cmd = [ + "./ds4", + "--metal", + *model_cli_args(model_path), + "-c", + str(ctx), + "--nothink", + "--temp", + "0", + "--seed", + "1", + "--batch-prompts-tsv", + str(batch_in), + "--batch-output-jsonl", + str(batch_out), + ] + t0 = time.time() + proc = subprocess.run(cmd, cwd=str(ROOT), text=True, capture_output=True, timeout=36000) + wall = time.time() - t0 + batch_err.write_text(proc.stderr, encoding="utf-8") + + rows = [] + if batch_out.exists(): + with batch_out.open(encoding="utf-8") as f: + for line in f: + if line.strip(): + rows.append(json.loads(line)) + if proc.returncode != 0 and not rows: + raise RuntimeError(f"long v2 batch failed for {model_name}: {proc.returncode}\n{proc.stderr[-3000:]}") + + by_id = {item["id"]: item for item in items} + results = [] + for row in rows: + item = by_id.get(row.get("id")) + if not item: + continue + scored = score_output(item, row) + results.append({ + **{k: v for k, v in item.items() if k != "prompt"}, + "model": model_name, + "returncode": row.get("returncode"), + "prompt_tokens": row.get("prompt_tokens"), + "generated_tokens": row.get("generated_tokens"), + "prefill_tps": row.get("prefill_tps"), + "generation_tps": row.get("generation_tps"), + "prefill_seconds": row.get("prefill_seconds"), + "decode_seconds": row.get("decode_seconds"), + "batch_wall_seconds": wall, + "output": (row.get("output") or "").strip(), + **scored, + }) + return results + + +def avg(vals): + xs = [float(x) for x in vals if x is not None] + return sum(xs) / len(xs) if xs else None + + +def summarize(results, selected_models): + summary = {} + for model in selected_models: + xs = [r for r in results if r["model"] == model] + kind_summary = {} + for kind, rows in group_by(xs, "kind").items(): + kind_summary[kind] = { + "n": len(rows), + "pass": sum(1 for r in rows if r["pass"]), + "pass_rate": sum(1 for r in rows if r["pass"]) / len(rows) if rows else None, + "avg_score": avg(r["score"] for r in rows), + } + summary[model] = { + "n": len(xs), + "pass": sum(1 for r in xs if r["pass"]), + "pass_rate": sum(1 for r in xs if r["pass"]) / len(xs) if xs else None, + "avg_score": avg(r["score"] for r in xs), + "avg_prefill_tps": avg(r.get("prefill_tps") for r in xs), + "avg_generation_tps": avg(r.get("generation_tps") for r in xs), + "avg_generated_tokens": avg(r.get("generated_tokens") for r in xs), + "kind_summary": kind_summary, + } + return summary + + +def group_by(rows, key): + out = defaultdict(list) + for row in rows: + out[row[key]].append(row) + return dict(out) + + +def write_scores_csv(path: Path, rows): + fields = [ + "id", "model", "kind", "pass", "score", "criteria_passed", "criteria_total", + "title", "bullet_count", "step_count", "hangul_ratio", + "prompt_tokens", "generated_tokens", "prefill_tps", "generation_tps", + "prefill_seconds", "decode_seconds", "output", + ] + with path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fields) + writer.writeheader() + for row in rows: + writer.writerow({k: row.get(k) for k in fields}) + + +def write_jsonl(path: Path, rows): + with path.open("w", encoding="utf-8") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def write_report(path: Path, summary, models): + lines = ["# DS4 Long Instruction v2 Report", ""] + lines.append("## Overall") + lines.append("") + lines.append("| model | pass | n | pass_rate | avg_score | prefill_tps | generation_tps | avg_generated_tokens |") + lines.append("|---|---:|---:|---:|---:|---:|---:|---:|") + for model in models: + s = summary[model] + lines.append( + f"| {model} | {s['pass']} | {s['n']} | {s['pass_rate']:.3f} | {s['avg_score']:.3f} | " + f"{s['avg_prefill_tps']:.2f} | {s['avg_generation_tps']:.2f} | {s['avg_generated_tokens']:.2f} |" + ) + lines.append("") + lines.append("## By Kind") + lines.append("") + for model in models: + lines.append(f"### {model}") + lines.append("") + lines.append("| kind | pass | n | pass_rate | avg_score |") + lines.append("|---|---:|---:|---:|---:|") + for kind, s in sorted(summary[model]["kind_summary"].items()): + lines.append(f"| {kind} | {s['pass']} | {s['n']} | {s['pass_rate']:.3f} | {s['avg_score']:.3f} |") + lines.append("") + path.write_text("\n".join(lines), encoding="utf-8") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--models", default="base,latestable5q4,layer10q4") + ap.add_argument("--out", type=Path, default=DEFAULT_OUT) + ap.add_argument("--ctx", type=int, default=4096) + ap.add_argument("--rerun", action="store_true") + args = ap.parse_args() + + selected = [m.strip() for m in args.models.split(",") if m.strip()] + unknown = [m for m in selected if m not in MODELS] + if unknown: + raise SystemExit(f"unknown model alias: {unknown}") + + args.out.mkdir(parents=True, exist_ok=True) + prompts = build_prompts() + write_jsonl(args.out / "long_instruction_v2_prompts.jsonl", prompts) + + all_results = [] + raw_path = args.out / "raw_results.jsonl" + if args.rerun: + raw_path.unlink(missing_ok=True) + done = set() + if raw_path.exists() and not args.rerun: + with raw_path.open(encoding="utf-8") as f: + for line in f: + if not line.strip(): + continue + row = json.loads(line) + done.add((row["model"], row["id"])) + all_results.append(row) + + with raw_path.open("a", encoding="utf-8") as raw_f: + for model in selected: + todo = [p for p in prompts if (model, p["id"]) not in done] + if not todo: + continue + print(f"RUN long-instruction-v2 model={model} n={len(todo)}", flush=True) + results = run_model(model, MODELS[model], todo, args.out, args.ctx) + for row in results: + raw_f.write(json.dumps(row, ensure_ascii=False) + "\n") + raw_f.flush() + all_results.extend(results) + + write_scores_csv(args.out / "scores.csv", all_results) + summary = summarize(all_results, selected) + (args.out / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + write_report(args.out / "REPORT.md", summary, selected) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/prepare_bitlift_sidecar_plan.py b/tools/prepare_bitlift_sidecar_plan.py new file mode 100644 index 00000000..745c14ab --- /dev/null +++ b/tools/prepare_bitlift_sidecar_plan.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +from pathlib import Path + + +DS4_N_LAYER = 43 +DS4_N_EXPERT = 256 + + +def load_manifest(path: Path): + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schema") == "ds4-ko-bitlift-manifest-v1": + return data + if "layers" in data and isinstance(data["layers"], dict): + by_layer = [ + {"layer": int(layer), "experts": experts} + for layer, experts in data["layers"].items() + ] + data = { + **data, + "schema": "ds4-ko-bitlift-manifest-v1", + "by_layer": by_layer, + "pair_count": sum(len(x["experts"]) for x in by_layer), + } + return data + if data.get("schema") != "ds4-ko-bitlift-manifest-v1": + raise SystemExit(f"unsupported manifest schema: {data.get('schema')!r}") + layers = data.get("by_layer") + if not isinstance(layers, list): + raise SystemExit("manifest is missing by_layer list") + return data + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def normalize_layers(manifest): + out = [] + seen_layers = set() + for row in manifest["by_layer"]: + layer = int(row["layer"]) + if layer < 0 or layer >= DS4_N_LAYER: + raise SystemExit(f"invalid layer index: {layer}") + if layer in seen_layers: + raise SystemExit(f"duplicate layer entry: {layer}") + seen_layers.add(layer) + + experts = [int(e) for e in row.get("experts", [])] + if not experts: + continue + if len(experts) > DS4_N_EXPERT: + raise SystemExit(f"layer {layer} has too many experts: {len(experts)}") + if len(set(experts)) != len(experts): + raise SystemExit(f"layer {layer} repeats an expert id") + bad = [e for e in experts if e < 0 or e >= DS4_N_EXPERT] + if bad: + raise SystemExit(f"layer {layer} has invalid expert ids: {bad[:8]}") + + out.append({ + "layer": layer, + "expert_count": len(experts), + "experts": experts, + "tensors": { + "gate": f"blk.{layer}.ffn_gate_exps.bitlift_q4.weight", + "up": f"blk.{layer}.ffn_up_exps.bitlift_q4.weight", + "down": f"blk.{layer}.ffn_down_exps.bitlift_q4.weight", + "ids": f"blk.{layer}.ffn_exps.bitlift_q4.ids", + }, + }) + return sorted(out, key=lambda x: x["layer"]) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("manifest", type=Path) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--sidecar-qtype", default="q4_k") + args = ap.parse_args() + + manifest = load_manifest(args.manifest) + layers = normalize_layers(manifest) + pair_count = sum(layer["expert_count"] for layer in layers) + if int(manifest.get("pair_count", pair_count)) != pair_count: + raise SystemExit(f"pair_count mismatch: manifest={manifest.get('pair_count')} computed={pair_count}") + + plan = { + "schema": "ds4-bitlift-sidecar-plan-v1", + "source_manifest": str(args.manifest), + "source_manifest_sha256": sha256_file(args.manifest), + "manifest_name": manifest.get("name"), + "sidecar_qtype": args.sidecar_qtype, + "runtime_tensor_contract": { + "gate": "blk.N.ffn_gate_exps.bitlift_q4.weight", + "up": "blk.N.ffn_up_exps.bitlift_q4.weight", + "down": "blk.N.ffn_down_exps.bitlift_q4.weight", + "ids": "blk.N.ffn_exps.bitlift_q4.ids", + "ids_dtype": "i32", + "lookup": "runtime builds expert_id -> sidecar_slot per layer", + }, + "layer_count": len(layers), + "expert_slot_count": pair_count, + "tensor_count_to_add": len(layers) * 4, + "layers": layers, + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps({ + "plan": str(args.out), + "layer_count": plan["layer_count"], + "expert_slot_count": plan["expert_slot_count"], + "tensor_count_to_add": plan["tensor_count_to_add"], + "manifest_sha256": plan["source_manifest_sha256"], + }, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/write_bitlift_sidecar_from_base_gguf.py b/tools/write_bitlift_sidecar_from_base_gguf.py new file mode 100755 index 00000000..5800a797 --- /dev/null +++ b/tools/write_bitlift_sidecar_from_base_gguf.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import ctypes +import json +import os +import platform +import struct +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from write_bitlift_sidecar_gguf import ( + DS4_N_EMBD, + DS4_N_EXPERT, + DS4_N_FF_EXP, + GGUF, + GGUF_BLOCK, + GGUF_TENSOR_I32, + GGUF_TENSOR_Q4_K, + align_up, + choose_layers, + load_plan, + parse_layers, + tensor_nbytes, + write_kv_string, + write_kv_u32, + write_str, + GGUF_MAGIC, + GGUF_VERSION, +) + + +GGUF_TENSOR_Q2_K = 10 +GGUF_TENSOR_IQ2_XXS = 16 + + +@dataclass +class OutputTensor: + name: str + dims: list[int] + typ: int + nbytes: int + rel_offset: int = 0 + source_name: str | None = None + source_type: int | None = None + experts: list[int] | None = None + ids: list[int] | None = None + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def build_quants_lib(out_dir: Path, force: bool = False) -> Path: + out_dir.mkdir(parents=True, exist_ok=True) + ext = ".dylib" if platform.system() == "Darwin" else ".so" + lib = out_dir / f"libds4quants{ext}" + src = repo_root() / "gguf-tools" / "quants.c" + header = repo_root() / "gguf-tools" / "quants.h" + if not force and lib.exists() and lib.stat().st_mtime >= max(src.stat().st_mtime, header.stat().st_mtime): + return lib + cmd = [ + "cc", + "-O3", + "-ffast-math", + "-std=c11", + "-fPIC", + "-dynamiclib" if platform.system() == "Darwin" else "-shared", + "-o", + str(lib), + str(src), + "-lm", + "-pthread", + ] + subprocess.run(cmd, check=True) + return lib + + +class QuantsLib: + def __init__(self, path: Path): + self.path = path + self.lib = ctypes.CDLL(str(path)) + self.lib.ds4q_row_size.argtypes = [ctypes.c_int, ctypes.c_longlong] + self.lib.ds4q_row_size.restype = ctypes.c_size_t + self.lib.ds4q_can_dequantize.argtypes = [ctypes.c_int] + self.lib.ds4q_can_dequantize.restype = ctypes.c_bool + self.lib.ds4q_quantize_init.argtypes = [ctypes.c_int] + self.lib.ds4q_quantize_init.restype = None + self.lib.ds4q_dequantize_chunk.argtypes = [ + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_longlong, + ctypes.c_longlong, + ] + self.lib.ds4q_dequantize_chunk.restype = ctypes.c_size_t + self.lib.ds4q_quantize_chunk.argtypes = [ + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_void_p, + ] + self.lib.ds4q_quantize_chunk.restype = ctypes.c_size_t + self.lib.ds4q_quantize_init(GGUF_TENSOR_Q4_K) + self.lib.ds4q_quantize_init(GGUF_TENSOR_IQ2_XXS) + + def row_size(self, typ: int, cols: int) -> int: + out = int(self.lib.ds4q_row_size(typ, cols)) + if out <= 0: + raise SystemExit(f"unsupported row layout type={typ} cols={cols}") + return out + + def can_dequantize(self, typ: int) -> bool: + return bool(self.lib.ds4q_can_dequantize(typ)) + + +def source_tensor_set(src: GGUF, layer: int): + names = { + "gate": f"blk.{layer}.ffn_gate_exps.weight", + "up": f"blk.{layer}.ffn_up_exps.weight", + "down": f"blk.{layer}.ffn_down_exps.weight", + } + tensors = {k: src.tensors.get(v) for k, v in names.items()} + if any(v is None for v in tensors.values()): + return None + expect = { + "gate": [DS4_N_EMBD, DS4_N_FF_EXP, DS4_N_EXPERT], + "up": [DS4_N_EMBD, DS4_N_FF_EXP, DS4_N_EXPERT], + "down": [DS4_N_FF_EXP, DS4_N_EMBD, DS4_N_EXPERT], + } + for kind, dims in expect.items(): + if tensors[kind].dims != dims: + raise SystemExit(f"{tensors[kind].name} has dims {tensors[kind].dims}, expected {dims}") + return tensors + + +def build_output_tensors(src: GGUF, qlib: QuantsLib, layers: list[dict]) -> list[OutputTensor]: + out: list[OutputTensor] = [] + for layer in layers: + il = int(layer["layer"]) + experts = [int(e) for e in layer["experts"]] + tensors = source_tensor_set(src, il) + if tensors is None: + raise SystemExit(f"source GGUF does not have routed tensors for layer {il}") + for kind, src_t in (("gate", tensors["gate"]), ("up", tensors["up"]), ("down", tensors["down"])): + if not qlib.can_dequantize(src_t.typ): + raise SystemExit(f"{src_t.name} type {src_t.typ} cannot be dequantized") + out_name = { + "gate": f"blk.{il}.ffn_gate_exps.bitlift_q4.weight", + "up": f"blk.{il}.ffn_up_exps.bitlift_q4.weight", + "down": f"blk.{il}.ffn_down_exps.bitlift_q4.weight", + }[kind] + dims = [src_t.dims[0], src_t.dims[1], len(experts)] + out.append(OutputTensor( + name=out_name, + dims=dims, + typ=GGUF_TENSOR_Q4_K, + nbytes=tensor_nbytes(GGUF_TENSOR_Q4_K, dims), + source_name=src_t.name, + source_type=src_t.typ, + experts=experts, + )) + out.append(OutputTensor( + name=f"blk.{il}.ffn_exps.bitlift_q4.ids", + dims=[len(experts)], + typ=GGUF_TENSOR_I32, + nbytes=tensor_nbytes(GGUF_TENSOR_I32, [len(experts)]), + ids=experts, + )) + return out + + +def assign_offsets(tensors: list[OutputTensor], alignment: int) -> int: + off = 0 + for t in tensors: + off = align_up(off, alignment) + t.rel_offset = off + off += t.nbytes + return off + + +def expert_bytes_for(src_type: int, dims: list[int], qlib: QuantsLib) -> int: + if len(dims) != 3: + raise SystemExit(f"expected 3D expert tensor dims, got {dims}") + return dims[1] * qlib.row_size(src_type, dims[0]) + + +def write_requantized_experts(src: GGUF, qlib: QuantsLib, out_f, t: OutputTensor, row_chunk: int) -> dict: + assert t.source_name is not None and t.source_type is not None and t.experts is not None + src_t = src.tensors[t.source_name] + cols = int(src_t.dims[0]) + rows = int(src_t.dims[1]) + src_row_bytes = qlib.row_size(src_t.typ, cols) + out_row_bytes = qlib.row_size(GGUF_TENSOR_Q4_K, cols) + src_expert_bytes = expert_bytes_for(src_t.typ, src_t.dims, qlib) + out_start = out_f.tell() + total_in = 0 + total_out = 0 + for expert in t.experts: + expert_base = src_t.abs_offset + expert * src_expert_bytes + for row0 in range(0, rows, row_chunk): + nr = min(row_chunk, rows - row0) + src.f.seek(expert_base + row0 * src_row_bytes) + src_bytes = src.f.read(nr * src_row_bytes) + if len(src_bytes) != nr * src_row_bytes: + raise SystemExit(f"unexpected EOF while reading {src_t.name} expert {expert}") + in_buf = ctypes.create_string_buffer(src_bytes, len(src_bytes)) + f32_buf = (ctypes.c_float * (nr * cols))() + q4_buf = ctypes.create_string_buffer(nr * out_row_bytes) + got = qlib.lib.ds4q_dequantize_chunk( + src_t.typ, + ctypes.cast(in_buf, ctypes.c_void_p), + ctypes.cast(f32_buf, ctypes.c_void_p), + nr, + cols, + ) + want_floats = nr * cols * ctypes.sizeof(ctypes.c_float) + if int(got) != want_floats: + raise SystemExit(f"dequantized unexpected byte count for {src_t.name}: {got} != {want_floats}") + wrote = qlib.lib.ds4q_quantize_chunk( + GGUF_TENSOR_Q4_K, + ctypes.cast(f32_buf, ctypes.c_void_p), + ctypes.cast(q4_buf, ctypes.c_void_p), + 0, + nr, + cols, + None, + ) + if int(wrote) != nr * out_row_bytes: + raise SystemExit(f"quantized unexpected byte count for {src_t.name}: {wrote} != {nr * out_row_bytes}") + out_f.write(q4_buf.raw) + total_in += len(src_bytes) + total_out += len(q4_buf.raw) + if out_f.tell() != out_start + t.nbytes: + raise SystemExit(f"wrote wrong byte count for {t.name}") + return {"source_bytes": total_in, "q4_bytes": total_out} + + +def write_sidecar(src: GGUF, qlib: QuantsLib, out_path: Path, tensors: list[OutputTensor], + alignment: int, name: str, row_chunk: int) -> tuple[int, dict]: + total_tensor_bytes = assign_offsets(tensors, alignment) + out_path.parent.mkdir(parents=True, exist_ok=True) + stats = {"source_bytes": 0, "q4_bytes": 0} + with out_path.open("wb") as out: + out.write(struct.pack(" dict[str, int]: + out: dict[str, int] = {} + for t in tensors: + if t.source_type is None: + continue + out[str(t.source_type)] = out.get(str(t.source_type), 0) + 1 + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--base", type=Path, required=True) + ap.add_argument("--plan", type=Path, required=True) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--name", default="DS4 Korean bitlift Q4 sidecar from base") + ap.add_argument("--layers", help="Comma/range filter, e.g. 37,38,40-42") + ap.add_argument("--row-chunk", type=int, default=128) + ap.add_argument("--build-dir", type=Path, default=Path("runs/bitlift_quants_lib")) + ap.add_argument("--rebuild-quants-lib", action="store_true") + ap.add_argument("--summary", type=Path) + args = ap.parse_args() + + if args.row_chunk <= 0: + raise SystemExit("--row-chunk must be positive") + + lib_path = build_quants_lib(args.build_dir, force=args.rebuild_quants_lib) + qlib = QuantsLib(lib_path) + plan = load_plan(args.plan) + layers = choose_layers(plan, parse_layers(args.layers)) + src = GGUF(args.base) + try: + tensors = build_output_tensors(src, qlib, layers) + if not tensors: + raise SystemExit("no sidecar tensors to write") + total_tensor_bytes, stats = write_sidecar(src, qlib, args.out, tensors, src.alignment, args.name, args.row_chunk) + finally: + src.close() + + layer_ids = sorted({int(t.name.split(".")[1]) for t in tensors if t.name.startswith("blk.")}) + expert_slots = sum(t.dims[0] for t in tensors if t.name.endswith(".ids")) + summary = { + "schema": "ds4-bitlift-sidecar-from-base-build-summary-v1", + "out": str(args.out), + "base": str(args.base), + "plan": str(args.plan), + "quants_lib": str(lib_path), + "row_chunk": args.row_chunk, + "layers": layer_ids, + "layer_count": len(layer_ids), + "expert_slot_count": expert_slots, + "tensor_count": len(tensors), + "source_type_tensor_counts": source_type_summary(tensors), + "tensor_payload_bytes": total_tensor_bytes, + "file_bytes": args.out.stat().st_size, + **stats, + } + if args.summary: + args.summary.parent.mkdir(parents=True, exist_ok=True) + args.summary.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/write_bitlift_sidecar_from_hf_fp4.py b/tools/write_bitlift_sidecar_from_hf_fp4.py new file mode 100644 index 00000000..b2a2ef38 --- /dev/null +++ b/tools/write_bitlift_sidecar_from_hf_fp4.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import ctypes +import json +import os +import struct +from dataclasses import dataclass +from pathlib import Path + +from write_bitlift_sidecar_gguf import ( + DS4_N_EMBD, + DS4_N_FF_EXP, + GGUF_MAGIC, + GGUF_VERSION, + GGUF_TENSOR_I32, + GGUF_TENSOR_Q4_K, + align_up, + choose_layers, + load_plan, + parse_layers, + tensor_nbytes, + write_kv_string, + write_kv_u32, + write_str, +) +from write_bitlift_sidecar_from_base_gguf import build_quants_lib + + +HF_PARTS = { + "gate": "w1", + "up": "w3", + "down": "w2", +} + + +@dataclass +class SafetensorEntry: + dtype: str + shape: list[int] + begin: int + end: int + + +@dataclass +class SafetensorShard: + path: Path + data_start: int + tensors: dict[str, SafetensorEntry] + + @classmethod + def open(cls, path: Path) -> "SafetensorShard": + with path.open("rb") as f: + header_len = struct.unpack(" str: + try: + return self.weight_map[name] + except KeyError as exc: + raise SystemExit(f"HF tensor not found in index: {name}") from exc + + def local_path_for(self, shard_name: str) -> Path: + return self.hf_dir / shard_name + + def local_has(self, name: str) -> bool: + shard_name = self.shard_for(name) + return self.local_path_for(shard_name).exists() + + def tensor(self, name: str) -> tuple[SafetensorShard, SafetensorEntry]: + shard_name = self.shard_for(name) + path = self.local_path_for(shard_name) + if not path.exists(): + raise SystemExit(f"required shard is missing locally: {path}") + shard = self.shards.get(shard_name) + if shard is None: + shard = SafetensorShard.open(path) + self.shards[shard_name] = shard + try: + return shard, shard.tensors[name] + except KeyError as exc: + raise SystemExit(f"HF tensor {name} missing in local shard {path}") from exc + + +class QuantsLib: + def __init__(self, path: Path): + self.path = path + self.lib = ctypes.CDLL(str(path)) + self.lib.ds4q_row_size.argtypes = [ctypes.c_int, ctypes.c_longlong] + self.lib.ds4q_row_size.restype = ctypes.c_size_t + self.lib.ds4q_quantize_init.argtypes = [ctypes.c_int] + self.lib.ds4q_quantize_init.restype = None + self.lib.ds4q_quantize_fp4_e8m0_to_q4_k_chunk.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_longlong, + ctypes.c_longlong, + ] + self.lib.ds4q_quantize_fp4_e8m0_to_q4_k_chunk.restype = ctypes.c_size_t + self.lib.ds4q_quantize_init(GGUF_TENSOR_Q4_K) + + def row_size(self, typ: int, cols: int) -> int: + out = int(self.lib.ds4q_row_size(typ, cols)) + if out <= 0: + raise SystemExit(f"unsupported row layout type={typ} cols={cols}") + return out + + +def hf_names(layer: int, expert: int, kind: str) -> tuple[str, str]: + part = HF_PARTS[kind] + prefix = f"layers.{layer}.ffn.experts.{expert}.{part}" + return f"{prefix}.weight", f"{prefix}.scale" + + +def validate_source_tensor(idx: HfIndex, layer: int, expert: int, kind: str) -> tuple[int, int]: + weight_name, scale_name = hf_names(layer, expert, kind) + _, w = idx.tensor(weight_name) + _, s = idx.tensor(scale_name) + if w.dtype != "I8" or s.dtype != "F8_E8M0": + raise SystemExit(f"{weight_name} / {scale_name} must be I8 + F8_E8M0, got {w.dtype} + {s.dtype}") + if len(w.shape) != 2 or len(s.shape) != 2: + raise SystemExit(f"{weight_name} must be 2D packed FP4") + rows = w.shape[0] + packed_cols = w.shape[1] + cols = packed_cols * 2 + if cols % 32: + raise SystemExit(f"{weight_name} logical cols not divisible by 32: {cols}") + if s.shape != [rows, cols // 32]: + raise SystemExit(f"{scale_name} shape {s.shape} does not match packed tensor {w.shape}") + expected = { + "gate": (DS4_N_FF_EXP, DS4_N_EMBD), + "up": (DS4_N_FF_EXP, DS4_N_EMBD), + "down": (DS4_N_EMBD, DS4_N_FF_EXP), + }[kind] + if (rows, cols) != expected: + raise SystemExit(f"{weight_name} logical shape rows/cols {(rows, cols)} != expected {expected}") + return rows, cols + + +def build_output_tensors(idx: HfIndex, layers: list[dict]) -> list[OutputTensor]: + out: list[OutputTensor] = [] + for layer in layers: + il = int(layer["layer"]) + experts = [int(e) for e in layer["experts"]] + if not experts: + continue + # Validate one representative per kind and local shard availability for every expert. + for kind in ("gate", "up", "down"): + for expert in experts: + weight_name, scale_name = hf_names(il, expert, kind) + if not idx.local_has(weight_name) or not idx.local_has(scale_name): + raise SystemExit(f"layer {il} expert {expert} {kind} is not fully available locally") + validate_source_tensor(idx, il, expert, kind) + dims = { + "gate": [DS4_N_EMBD, DS4_N_FF_EXP, len(experts)], + "up": [DS4_N_EMBD, DS4_N_FF_EXP, len(experts)], + "down": [DS4_N_FF_EXP, DS4_N_EMBD, len(experts)], + }[kind] + out.append(OutputTensor( + name={ + "gate": f"blk.{il}.ffn_gate_exps.bitlift_q4.weight", + "up": f"blk.{il}.ffn_up_exps.bitlift_q4.weight", + "down": f"blk.{il}.ffn_down_exps.bitlift_q4.weight", + }[kind], + dims=dims, + typ=GGUF_TENSOR_Q4_K, + nbytes=tensor_nbytes(GGUF_TENSOR_Q4_K, dims), + layer=il, + kind=kind, + experts=experts, + )) + out.append(OutputTensor( + name=f"blk.{il}.ffn_exps.bitlift_q4.ids", + dims=[len(experts)], + typ=GGUF_TENSOR_I32, + nbytes=tensor_nbytes(GGUF_TENSOR_I32, [len(experts)]), + ids=experts, + )) + return out + + +def assign_offsets(tensors: list[OutputTensor], alignment: int) -> int: + off = 0 + for t in tensors: + off = align_up(off, alignment) + t.rel_offset = off + off += t.nbytes + return off + + +def read_exact(path: Path, offset: int, nbytes: int) -> bytes: + with path.open("rb") as f: + f.seek(offset) + data = f.read(nbytes) + if len(data) != nbytes: + raise SystemExit(f"unexpected EOF while reading {path}") + return data + + +def write_quantized_experts(idx: HfIndex, qlib: QuantsLib, out_f, t: OutputTensor, row_chunk: int) -> dict: + assert t.layer is not None and t.kind is not None and t.experts is not None + cols = int(t.dims[0]) + rows = int(t.dims[1]) + out_row_bytes = qlib.row_size(GGUF_TENSOR_Q4_K, cols) + out_start = out_f.tell() + source_bytes = 0 + q4_bytes = 0 + for expert in t.experts: + weight_name, scale_name = hf_names(t.layer, expert, t.kind) + w_shard, w = idx.tensor(weight_name) + s_shard, s = idx.tensor(scale_name) + packed_cols = w.shape[1] + scale_cols = s.shape[1] + if w.shape[0] != rows or packed_cols * 2 != cols: + raise SystemExit(f"shape mismatch for {weight_name}: {w.shape}, expected rows={rows} cols={cols}") + for row0 in range(0, rows, row_chunk): + nr = min(row_chunk, rows - row0) + w_off = w_shard.data_start + w.begin + row0 * packed_cols + s_off = s_shard.data_start + s.begin + row0 * scale_cols + w_bytes = read_exact(w_shard.path, w_off, nr * packed_cols) + s_bytes = read_exact(s_shard.path, s_off, nr * scale_cols) + w_buf = ctypes.create_string_buffer(w_bytes, len(w_bytes)) + s_buf = ctypes.create_string_buffer(s_bytes, len(s_bytes)) + q4_buf = ctypes.create_string_buffer(nr * out_row_bytes) + wrote = qlib.lib.ds4q_quantize_fp4_e8m0_to_q4_k_chunk( + ctypes.cast(w_buf, ctypes.c_void_p), + ctypes.cast(s_buf, ctypes.c_void_p), + ctypes.cast(q4_buf, ctypes.c_void_p), + nr, + packed_cols, + ) + if int(wrote) != nr * out_row_bytes: + raise SystemExit(f"FP4->Q4_K wrote unexpected byte count for {weight_name}: {wrote}") + out_f.write(q4_buf.raw) + source_bytes += len(w_bytes) + len(s_bytes) + q4_bytes += len(q4_buf.raw) + if out_f.tell() != out_start + t.nbytes: + raise SystemExit(f"wrote wrong byte count for {t.name}") + return {"source_bytes": source_bytes, "q4_bytes": q4_bytes} + + +def write_sidecar(idx: HfIndex, qlib: QuantsLib, out_path: Path, tensors: list[OutputTensor], + alignment: int, name: str, row_chunk: int) -> tuple[int, dict]: + total_tensor_bytes = assign_offsets(tensors, alignment) + out_path.parent.mkdir(parents=True, exist_ok=True) + stats = {"source_bytes": 0, "q4_bytes": 0} + with out_path.open("wb") as out: + out.write(struct.pack("q4 {t.name} layer={t.layer} experts={len(t.experts or [])}", flush=True) + part = write_quantized_experts(idx, qlib, out, t, row_chunk) + stats["source_bytes"] += part["source_bytes"] + stats["q4_bytes"] += part["q4_bytes"] + if out.tell() != want + t.nbytes: + raise SystemExit(f"wrote wrong byte count for {t.name}") + return total_tensor_bytes, stats + + +def filter_locally_available_layers(idx: HfIndex, layers: list[dict]) -> tuple[list[dict], list[int]]: + available = [] + skipped = [] + for layer in layers: + il = int(layer["layer"]) + ok = True + for expert in layer["experts"]: + for kind in ("gate", "up", "down"): + weight_name, scale_name = hf_names(il, int(expert), kind) + if not idx.local_has(weight_name) or not idx.local_has(scale_name): + ok = False + break + if not ok: + break + if ok: + available.append(layer) + else: + skipped.append(il) + return available, skipped + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--hf-dir", type=Path, required=True) + ap.add_argument("--plan", type=Path, required=True) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--name", default="DS4 Korean bitlift Q4 sidecar from HF FP4 source") + ap.add_argument("--layers", help="Comma/range filter, e.g. 37,38,40-42") + ap.add_argument("--row-chunk", type=int, default=128) + ap.add_argument("--build-dir", type=Path, default=Path("runs/bitlift_quants_lib")) + ap.add_argument("--rebuild-quants-lib", action="store_true") + ap.add_argument("--skip-missing-layers", action="store_true") + ap.add_argument("--summary", type=Path) + args = ap.parse_args() + + if args.row_chunk <= 0: + raise SystemExit("--row-chunk must be positive") + if not (args.hf_dir / "model.safetensors.index.json").exists(): + raise SystemExit(f"missing safetensors index in {args.hf_dir}") + + idx = HfIndex(args.hf_dir) + plan = load_plan(args.plan) + layers = choose_layers(plan, parse_layers(args.layers)) + if args.skip_missing_layers: + layers, skipped = filter_locally_available_layers(idx, layers) + else: + skipped = [] + if not layers: + raise SystemExit("no locally available sidecar layers to write") + + lib_path = build_quants_lib(args.build_dir, force=args.rebuild_quants_lib) + qlib = QuantsLib(lib_path) + tensors = build_output_tensors(idx, layers) + total_tensor_bytes, stats = write_sidecar(idx, qlib, args.out, tensors, 32, args.name, args.row_chunk) + + layer_ids = sorted({int(t.name.split(".")[1]) for t in tensors if t.name.startswith("blk.")}) + expert_slots = sum(t.dims[0] for t in tensors if t.name.endswith(".ids")) + shards = sorted({idx.shard_for(hf_names(il, e, kind)[0]) + for ilayer in layers + for il in [int(ilayer["layer"])] + for e in [int(x) for x in ilayer["experts"]] + for kind in ("gate", "up", "down")}) + summary = { + "schema": "ds4-bitlift-sidecar-from-hf-fp4-build-summary-v1", + "out": str(args.out), + "hf_dir": str(args.hf_dir), + "plan": str(args.plan), + "quants_lib": str(lib_path), + "row_chunk": args.row_chunk, + "layers": layer_ids, + "skipped_missing_layers": sorted(set(skipped)), + "layer_count": len(layer_ids), + "expert_slot_count": expert_slots, + "tensor_count": len(tensors), + "source_format": "packed_fp4_i8_plus_f8_e8m0_scales", + "source_shards": shards, + "tensor_payload_bytes": total_tensor_bytes, + "file_bytes": args.out.stat().st_size, + **stats, + } + if args.summary: + args.summary.parent.mkdir(parents=True, exist_ok=True) + args.summary.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/write_bitlift_sidecar_from_hf_fp8.py b/tools/write_bitlift_sidecar_from_hf_fp8.py new file mode 100644 index 00000000..97aa42fc --- /dev/null +++ b/tools/write_bitlift_sidecar_from_hf_fp8.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import ctypes +import json +import struct +from pathlib import Path + +from write_bitlift_sidecar_gguf import ( + DS4_N_EMBD, + DS4_N_FF_EXP, + GGUF_MAGIC, + GGUF_VERSION, + GGUF_TENSOR_I32, + GGUF_TENSOR_Q4_K, + align_up, + choose_layers, + load_plan, + parse_layers, + tensor_nbytes, + write_kv_string, + write_kv_u32, + write_str, +) +from write_bitlift_sidecar_from_base_gguf import build_quants_lib +from write_bitlift_sidecar_from_hf_fp4 import HfIndex, OutputTensor, hf_names, read_exact + + +def validate_source_tensor(idx: HfIndex, layer: int, expert: int, kind: str) -> tuple[int, int]: + weight_name, scale_name = hf_names(layer, expert, kind) + _, w = idx.tensor(weight_name) + _, s = idx.tensor(scale_name) + if w.dtype != "F8_E4M3" or s.dtype != "F32": + raise SystemExit(f"{weight_name} / {scale_name} must be F8_E4M3 + F32, got {w.dtype} + {s.dtype}") + if len(w.shape) != 2 or len(s.shape) != 2: + raise SystemExit(f"{weight_name} and {scale_name} must be 2D tensors") + rows, cols = w.shape + expected = { + "gate": (DS4_N_FF_EXP, DS4_N_EMBD), + "up": (DS4_N_FF_EXP, DS4_N_EMBD), + "down": (DS4_N_EMBD, DS4_N_FF_EXP), + }[kind] + if (rows, cols) != expected: + raise SystemExit(f"{weight_name} shape rows/cols {(rows, cols)} != expected {expected}") + if rows % 128 or cols % 128: + raise SystemExit(f"{weight_name} shape must be divisible by 128x128: {(rows, cols)}") + if s.shape != [rows // 128, cols // 128]: + raise SystemExit(f"{scale_name} shape {s.shape} does not match FP8 weight {w.shape}") + return rows, cols + + +def build_output_tensors(idx: HfIndex, layers: list[dict]) -> list[OutputTensor]: + out: list[OutputTensor] = [] + for layer in layers: + il = int(layer["layer"]) + experts = [int(e) for e in layer["experts"]] + if not experts: + continue + for kind in ("gate", "up", "down"): + for expert in experts: + weight_name, scale_name = hf_names(il, expert, kind) + if not idx.local_has(weight_name) or not idx.local_has(scale_name): + raise SystemExit(f"layer {il} expert {expert} {kind} is not fully available locally") + validate_source_tensor(idx, il, expert, kind) + dims = { + "gate": [DS4_N_EMBD, DS4_N_FF_EXP, len(experts)], + "up": [DS4_N_EMBD, DS4_N_FF_EXP, len(experts)], + "down": [DS4_N_FF_EXP, DS4_N_EMBD, len(experts)], + }[kind] + out.append(OutputTensor( + name={ + "gate": f"blk.{il}.ffn_gate_exps.bitlift_q4.weight", + "up": f"blk.{il}.ffn_up_exps.bitlift_q4.weight", + "down": f"blk.{il}.ffn_down_exps.bitlift_q4.weight", + }[kind], + dims=dims, + typ=GGUF_TENSOR_Q4_K, + nbytes=tensor_nbytes(GGUF_TENSOR_Q4_K, dims), + layer=il, + kind=kind, + experts=experts, + )) + out.append(OutputTensor( + name=f"blk.{il}.ffn_exps.bitlift_q4.ids", + dims=[len(experts)], + typ=GGUF_TENSOR_I32, + nbytes=tensor_nbytes(GGUF_TENSOR_I32, [len(experts)]), + ids=experts, + )) + return out + + +class QuantsLib: + def __init__(self, path: Path): + self.path = path + self.lib = ctypes.CDLL(str(path)) + self.lib.ds4q_row_size.argtypes = [ctypes.c_int, ctypes.c_longlong] + self.lib.ds4q_row_size.restype = ctypes.c_size_t + self.lib.ds4q_quantize_init.argtypes = [ctypes.c_int] + self.lib.ds4q_quantize_init.restype = None + self.lib.ds4q_quantize_fp8_f32_to_q4_k_chunk.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_longlong, + ] + self.lib.ds4q_quantize_fp8_f32_to_q4_k_chunk.restype = ctypes.c_size_t + self.lib.ds4q_quantize_init(GGUF_TENSOR_Q4_K) + + def row_size(self, typ: int, cols: int) -> int: + out = int(self.lib.ds4q_row_size(typ, cols)) + if out <= 0: + raise SystemExit(f"unsupported row layout type={typ} cols={cols}") + return out + + +def write_quantized_experts(idx: HfIndex, qlib: QuantsLib, out_f, t: OutputTensor, row_chunk: int) -> dict: + assert t.layer is not None and t.kind is not None and t.experts is not None + if row_chunk % 128: + raise SystemExit("--row-chunk must be a multiple of 128 for FP8/F32 block scales") + cols = int(t.dims[0]) + rows = int(t.dims[1]) + out_row_bytes = qlib.row_size(GGUF_TENSOR_Q4_K, cols) + out_start = out_f.tell() + source_bytes = 0 + q4_bytes = 0 + for expert in t.experts: + weight_name, scale_name = hf_names(t.layer, expert, t.kind) + w_shard, w = idx.tensor(weight_name) + s_shard, s = idx.tensor(scale_name) + scale_cols = s.shape[1] + if w.shape[0] != rows or w.shape[1] != cols: + raise SystemExit(f"shape mismatch for {weight_name}: {w.shape}, expected rows={rows} cols={cols}") + for row0 in range(0, rows, row_chunk): + nr = min(row_chunk, rows - row0) + if nr % 128: + raise SystemExit(f"FP8 chunk rows must be a multiple of 128, got {nr}") + scale_rows = nr // 128 + w_off = w_shard.data_start + w.begin + row0 * cols + s_off = s_shard.data_start + s.begin + (row0 // 128) * scale_cols * 4 + w_bytes = read_exact(w_shard.path, w_off, nr * cols) + s_bytes = read_exact(s_shard.path, s_off, scale_rows * scale_cols * 4) + w_buf = ctypes.create_string_buffer(w_bytes, len(w_bytes)) + s_buf = ctypes.create_string_buffer(s_bytes, len(s_bytes)) + q4_buf = ctypes.create_string_buffer(nr * out_row_bytes) + wrote = qlib.lib.ds4q_quantize_fp8_f32_to_q4_k_chunk( + ctypes.cast(w_buf, ctypes.c_void_p), + ctypes.cast(s_buf, ctypes.c_void_p), + ctypes.cast(q4_buf, ctypes.c_void_p), + nr, + cols, + scale_cols, + ) + if int(wrote) != nr * out_row_bytes: + raise SystemExit(f"FP8->Q4_K wrote unexpected byte count for {weight_name}: {wrote}") + out_f.write(q4_buf.raw) + source_bytes += len(w_bytes) + len(s_bytes) + q4_bytes += len(q4_buf.raw) + if out_f.tell() != out_start + t.nbytes: + raise SystemExit(f"wrote wrong byte count for {t.name}") + return {"source_bytes": source_bytes, "q4_bytes": q4_bytes} + + +def assign_offsets(tensors: list[OutputTensor], alignment: int) -> int: + off = 0 + for t in tensors: + off = align_up(off, alignment) + t.rel_offset = off + off += t.nbytes + return off + + +def write_sidecar(idx: HfIndex, qlib: QuantsLib, out_path: Path, tensors: list[OutputTensor], + alignment: int, name: str, row_chunk: int) -> tuple[int, dict]: + total_tensor_bytes = assign_offsets(tensors, alignment) + out_path.parent.mkdir(parents=True, exist_ok=True) + stats = {"source_bytes": 0, "q4_bytes": 0} + with out_path.open("wb") as out: + out.write(struct.pack("q4 {t.name} layer={t.layer} experts={len(t.experts or [])}", flush=True) + part = write_quantized_experts(idx, qlib, out, t, row_chunk) + stats["source_bytes"] += part["source_bytes"] + stats["q4_bytes"] += part["q4_bytes"] + if out.tell() != want + t.nbytes: + raise SystemExit(f"wrote wrong byte count for {t.name}") + return total_tensor_bytes, stats + + +def filter_locally_available_layers(idx: HfIndex, layers: list[dict]) -> tuple[list[dict], list[int]]: + available = [] + skipped = [] + for layer in layers: + il = int(layer["layer"]) + ok = True + for expert in layer["experts"]: + for kind in ("gate", "up", "down"): + weight_name, scale_name = hf_names(il, int(expert), kind) + if not idx.local_has(weight_name) or not idx.local_has(scale_name): + ok = False + break + if not ok: + break + if ok: + available.append(layer) + else: + skipped.append(il) + return available, skipped + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--hf-dir", type=Path, required=True) + ap.add_argument("--plan", type=Path, required=True) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--name", default="DS4 Korean bitlift Q4 sidecar from HF Base FP8 source") + ap.add_argument("--layers", help="Comma/range filter, e.g. 10 or 37,38,40-42") + ap.add_argument("--row-chunk", type=int, default=128) + ap.add_argument("--build-dir", type=Path, default=Path("runs/bitlift_quants_lib")) + ap.add_argument("--rebuild-quants-lib", action="store_true") + ap.add_argument("--skip-missing-layers", action="store_true") + ap.add_argument("--summary", type=Path) + args = ap.parse_args() + + if args.row_chunk <= 0 or args.row_chunk % 128: + raise SystemExit("--row-chunk must be a positive multiple of 128") + if not (args.hf_dir / "model.safetensors.index.json").exists(): + raise SystemExit(f"missing safetensors index in {args.hf_dir}") + + idx = HfIndex(args.hf_dir) + plan = load_plan(args.plan) + layers = choose_layers(plan, parse_layers(args.layers)) + if args.skip_missing_layers: + layers, skipped = filter_locally_available_layers(idx, layers) + else: + skipped = [] + if not layers: + raise SystemExit("no locally available sidecar layers to write") + + lib_path = build_quants_lib(args.build_dir, force=args.rebuild_quants_lib) + qlib = QuantsLib(lib_path) + tensors = build_output_tensors(idx, layers) + total_tensor_bytes, stats = write_sidecar(idx, qlib, args.out, tensors, 32, args.name, args.row_chunk) + + layer_ids = sorted({int(t.name.split(".")[1]) for t in tensors if t.name.startswith("blk.")}) + expert_slots = sum(t.dims[0] for t in tensors if t.name.endswith(".ids")) + shards = sorted({idx.shard_for(hf_names(il, e, kind)[0]) + for ilayer in layers + for il in [int(ilayer["layer"])] + for e in [int(x) for x in ilayer["experts"]] + for kind in ("gate", "up", "down")}) + summary = { + "schema": "ds4-bitlift-sidecar-from-hf-base-fp8-build-summary-v1", + "out": str(args.out), + "hf_dir": str(args.hf_dir), + "plan": str(args.plan), + "quants_lib": str(lib_path), + "row_chunk": args.row_chunk, + "layers": layer_ids, + "skipped_missing_layers": sorted(set(skipped)), + "layer_count": len(layer_ids), + "expert_slot_count": expert_slots, + "tensor_count": len(tensors), + "source_format": "official_base_fp8_e4m3_plus_f32_block_scales", + "source_shards": shards, + "tensor_payload_bytes": total_tensor_bytes, + "file_bytes": args.out.stat().st_size, + **stats, + } + if args.summary: + args.summary.parent.mkdir(parents=True, exist_ok=True) + args.summary.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/write_bitlift_sidecar_gguf.py b/tools/write_bitlift_sidecar_gguf.py new file mode 100755 index 00000000..e93ae233 --- /dev/null +++ b/tools/write_bitlift_sidecar_gguf.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import struct +from dataclasses import dataclass +from pathlib import Path + + +GGUF_MAGIC = 0x46554747 +GGUF_VERSION = 3 +GGUF_VALUE_UINT32 = 4 +GGUF_VALUE_STRING = 8 +GGUF_TENSOR_Q4_K = 12 +GGUF_TENSOR_I32 = 26 +GGUF_BLOCK = { + 0: (1, 4), + 1: (1, 2), + 2: (32, 18), + 3: (32, 20), + 6: (32, 22), + 7: (32, 24), + 8: (32, 34), + 9: (32, 40), + 10: (256, 84), + 11: (256, 110), + GGUF_TENSOR_Q4_K: (256, 144), + 13: (256, 176), + 14: (256, 210), + 15: (256, 292), + 16: (256, 66), + 17: (256, 74), + 18: (256, 98), + 19: (256, 110), + 20: (256, 50), + 21: (256, 110), + 22: (256, 82), + 23: (256, 136), + 24: (1, 1), + 25: (1, 2), + GGUF_TENSOR_I32: (1, 4), + 27: (1, 8), + 28: (1, 8), + 29: (256, 56), + 30: (1, 2), +} +DS4_N_EMBD = 4096 +DS4_N_FF_EXP = 2048 +DS4_N_EXPERT = 256 + + +@dataclass +class TensorInfo: + name: str + dims: list[int] + typ: int + rel_offset: int + abs_offset: int + nbytes: int + + +@dataclass +class OutputTensor: + name: str + dims: list[int] + typ: int + nbytes: int + rel_offset: int = 0 + source: tuple[str, list[int]] | None = None + ids: list[int] | None = None + + +class GGUF: + def __init__(self, path: Path): + self.path = path + self.f = path.open("rb") + self.version = 0 + self.n_tensors = 0 + self.n_kv = 0 + self.alignment = 32 + self.tensor_data_pos = 0 + self.tensors: dict[str, TensorInfo] = {} + self._parse() + + def close(self): + self.f.close() + + def read_u32(self) -> int: + return struct.unpack(" int: + return struct.unpack(" str: + n = self.read_u64() + return self.f.read(n).decode("utf-8") + + def skip_value(self, typ: int): + sizes = { + 0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, + 10: 8, 11: 8, 12: 8, + } + if typ in sizes: + self.f.seek(sizes[typ], os.SEEK_CUR) + return + if typ == GGUF_VALUE_STRING: + n = self.read_u64() + self.f.seek(n, os.SEEK_CUR) + return + if typ == 9: + item_type = self.read_u32() + n = self.read_u64() + if item_type in sizes: + self.f.seek(sizes[item_type] * n, os.SEEK_CUR) + else: + for _ in range(n): + self.skip_value(item_type) + return + raise SystemExit(f"unsupported GGUF metadata value type {typ}") + + def _parse(self): + magic = self.read_u32() + if magic != GGUF_MAGIC: + raise SystemExit(f"{self.path} is not GGUF") + self.version = self.read_u32() + if self.version != GGUF_VERSION: + raise SystemExit(f"{self.path} is GGUF v{self.version}, expected v3") + self.n_tensors = self.read_u64() + self.n_kv = self.read_u64() + for _ in range(self.n_kv): + key = self.read_str() + typ = self.read_u32() + if key == "general.alignment" and typ == GGUF_VALUE_UINT32: + pos = self.f.tell() + self.alignment = self.read_u32() + self.f.seek(pos) + self.skip_value(typ) + for _ in range(self.n_tensors): + name = self.read_str() + nd = self.read_u32() + dims = [self.read_u64() for _ in range(nd)] + typ = self.read_u32() + rel_offset = self.read_u64() + nbytes = tensor_nbytes(typ, dims) + self.tensors[name] = TensorInfo( + name=name, + dims=dims, + typ=typ, + rel_offset=rel_offset, + abs_offset=0, + nbytes=nbytes, + ) + self.tensor_data_pos = align_up(self.f.tell(), self.alignment) + for t in self.tensors.values(): + t.abs_offset = self.tensor_data_pos + t.rel_offset + + +def align_up(v: int, a: int) -> int: + rem = v % a + return v if rem == 0 else v + a - rem + + +def tensor_nbytes(typ: int, dims: list[int]) -> int: + elems = 1 + for d in dims: + elems *= d + try: + block_elems, block_bytes = GGUF_BLOCK[typ] + except KeyError as exc: + raise SystemExit(f"unsupported tensor type {typ}") from exc + return ((elems + block_elems - 1) // block_elems) * block_bytes + + +def row_bytes_q4(cols: int) -> int: + if cols % 256 != 0: + raise SystemExit(f"Q4_K row width must be divisible by 256, got {cols}") + return (cols // 256) * 144 + + +def expert_bytes_for(t: TensorInfo) -> int: + if t.typ != GGUF_TENSOR_Q4_K or len(t.dims) != 3: + raise SystemExit(f"{t.name} is not a 3D Q4_K expert tensor") + return t.dims[1] * row_bytes_q4(t.dims[0]) + + +def write_str(f, s: str): + b = s.encode("utf-8") + f.write(struct.pack(" dict: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schema") != "ds4-bitlift-sidecar-plan-v1": + raise SystemExit(f"unsupported sidecar plan schema: {data.get('schema')!r}") + return data + + +def choose_layers(plan: dict, only_layers: set[int] | None) -> list[dict]: + layers = plan.get("layers", []) + out = [] + for layer in layers: + il = int(layer["layer"]) + if only_layers is not None and il not in only_layers: + continue + experts = [int(e) for e in layer.get("experts", [])] + if not experts: + continue + if len(set(experts)) != len(experts): + raise SystemExit(f"layer {il} repeats expert ids") + bad = [e for e in experts if e < 0 or e >= DS4_N_EXPERT] + if bad: + raise SystemExit(f"layer {il} has invalid expert ids: {bad[:8]}") + out.append({"layer": il, "experts": experts}) + return out + + +def source_tensor_set(src: GGUF, layer: int): + names = { + "gate": f"blk.{layer}.ffn_gate_exps.weight", + "up": f"blk.{layer}.ffn_up_exps.weight", + "down": f"blk.{layer}.ffn_down_exps.weight", + } + tensors = {k: src.tensors.get(v) for k, v in names.items()} + if any(v is None for v in tensors.values()): + return None + if tensors["gate"].typ != GGUF_TENSOR_Q4_K or tensors["up"].typ != GGUF_TENSOR_Q4_K or tensors["down"].typ != GGUF_TENSOR_Q4_K: + return None + expect = { + "gate": [DS4_N_EMBD, DS4_N_FF_EXP, DS4_N_EXPERT], + "up": [DS4_N_EMBD, DS4_N_FF_EXP, DS4_N_EXPERT], + "down": [DS4_N_FF_EXP, DS4_N_EMBD, DS4_N_EXPERT], + } + for k, dims in expect.items(): + if tensors[k].dims != dims: + raise SystemExit(f"{tensors[k].name} has dims {tensors[k].dims}, expected {dims}") + return tensors + + +def build_output_tensors(src: GGUF, layers: list[dict], allow_missing: bool): + out: list[OutputTensor] = [] + skipped = [] + for layer in layers: + il = layer["layer"] + experts = layer["experts"] + tensors = source_tensor_set(src, il) + if tensors is None: + if allow_missing: + skipped.append(il) + continue + raise SystemExit(f"source GGUF does not have Q4 routed tensors for layer {il}") + n = len(experts) + for kind, src_t in (("gate", tensors["gate"]), ("up", tensors["up"]), ("down", tensors["down"])): + out_name = { + "gate": f"blk.{il}.ffn_gate_exps.bitlift_q4.weight", + "up": f"blk.{il}.ffn_up_exps.bitlift_q4.weight", + "down": f"blk.{il}.ffn_down_exps.bitlift_q4.weight", + }[kind] + dims = [src_t.dims[0], src_t.dims[1], n] + out.append(OutputTensor( + name=out_name, + dims=dims, + typ=GGUF_TENSOR_Q4_K, + nbytes=tensor_nbytes(GGUF_TENSOR_Q4_K, dims), + source=(src_t.name, experts), + )) + ids_dims = [n] + out.append(OutputTensor( + name=f"blk.{il}.ffn_exps.bitlift_q4.ids", + dims=ids_dims, + typ=GGUF_TENSOR_I32, + nbytes=tensor_nbytes(GGUF_TENSOR_I32, ids_dims), + ids=experts, + )) + return out, skipped + + +def assign_offsets(tensors: list[OutputTensor], alignment: int): + off = 0 + for t in tensors: + off = align_up(off, alignment) + t.rel_offset = off + off += t.nbytes + return off + + +def write_sidecar(src: GGUF, out_path: Path, tensors: list[OutputTensor], alignment: int, name: str): + total_tensor_bytes = assign_offsets(tensors, alignment) + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("wb") as out: + out.write(struct.pack(" set[int] | None: + if not s: + return None + out = set() + for part in s.split(","): + part = part.strip() + if not part: + continue + if "-" in part: + a, b = part.split("-", 1) + out.update(range(int(a), int(b) + 1)) + else: + out.add(int(part)) + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--base", type=Path, help="Reserved for provenance; not copied into sidecar.") + ap.add_argument("--source-q4", type=Path, required=True) + ap.add_argument("--plan", type=Path, required=True) + ap.add_argument("--out", type=Path, required=True) + ap.add_argument("--name", default="DS4 Korean bitlift Q4 sidecar") + ap.add_argument("--layers", help="Comma/range filter, e.g. 37,38,40-42") + ap.add_argument("--allow-missing-source-q4", action="store_true") + ap.add_argument("--summary", type=Path) + args = ap.parse_args() + + plan = load_plan(args.plan) + layers = choose_layers(plan, parse_layers(args.layers)) + src = GGUF(args.source_q4) + try: + tensors, skipped = build_output_tensors(src, layers, args.allow_missing_source_q4) + if not tensors: + raise SystemExit("no sidecar tensors to write") + total_tensor_bytes = write_sidecar(src, args.out, tensors, src.alignment, args.name) + finally: + src.close() + + layer_ids = sorted({int(t.name.split(".")[1]) for t in tensors if t.name.startswith("blk.")}) + expert_slots = sum(t.dims[0] for t in tensors if t.name.endswith(".ids")) + summary = { + "schema": "ds4-bitlift-sidecar-build-summary-v1", + "out": str(args.out), + "source_q4": str(args.source_q4), + "base": str(args.base) if args.base else None, + "plan": str(args.plan), + "layers": layer_ids, + "layer_count": len(layer_ids), + "expert_slot_count": expert_slots, + "tensor_count": len(tensors), + "tensor_payload_bytes": total_tensor_bytes, + "file_bytes": args.out.stat().st_size, + "skipped_layers_missing_source_q4": skipped, + } + if args.summary: + args.summary.parent.mkdir(parents=True, exist_ok=True) + args.summary.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main()